mirror of
https://github.com/Comfy-Org/ComfyUI_frontend.git
synced 2026-07-17 17:28:58 +00:00
Compare commits
6 Commits
fix/ppform
...
split/memb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b0c5775f49 | ||
|
|
f5c5597d70 | ||
|
|
85f9a5347d | ||
|
|
f2d632385b | ||
|
|
fa2e174d81 | ||
|
|
a898e39d20 |
4
.github/workflows/cla.yml
vendored
4
.github/workflows/cla.yml
vendored
@@ -38,11 +38,9 @@ jobs:
|
||||
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
|
||||
PR_AUTHOR: ${{ github.event.pull_request.user.login || github.event.issue.user.login }}
|
||||
BASE_ALLOWLIST: action@github.com,actions-user,ampagent,claude,comfy-pr-bot,GitHub Action,github-actions,github-actions[bot],Glary Bot,Glary-Bot,*[bot]
|
||||
# For each commit emit the GitHub login when the author/committer email resolves to a GitHub account
|
||||
# otherwise fall back to the raw git name.
|
||||
run: |
|
||||
others=$(gh api "repos/${{ github.repository }}/pulls/${PR_NUMBER}/commits" --paginate \
|
||||
--jq '.[] | (.author.login // .commit.author.name // empty), (.committer.login // .commit.committer.name // empty)' \
|
||||
--jq '.[] | (.author.login // empty), (.committer.login // empty)' \
|
||||
| sort -u | grep -vix "${PR_AUTHOR}" | paste -sd, -)
|
||||
if [ -n "$others" ]; then
|
||||
echo "allowlist=${BASE_ALLOWLIST},${others}" >> "$GITHUB_OUTPUT"
|
||||
|
||||
75
.github/workflows/pr-backport.yaml
vendored
75
.github/workflows/pr-backport.yaml
vendored
@@ -278,49 +278,32 @@ jobs:
|
||||
continue
|
||||
fi
|
||||
|
||||
# Create backport branch. A failure here (e.g. dirty state left
|
||||
# by a prior target) must not abort the loop and skip remaining
|
||||
# targets, so fall back to a clean checkout and record the error.
|
||||
if ! git checkout -B "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"; then
|
||||
echo "::error::Failed to create branch ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:branch-create-failed "
|
||||
git checkout main || git checkout -f main
|
||||
echo "::endgroup::"
|
||||
continue
|
||||
fi
|
||||
# Create backport branch
|
||||
git checkout -b "${BACKPORT_BRANCH}" "origin/${TARGET_BRANCH}"
|
||||
|
||||
# Try cherry-pick
|
||||
if git cherry-pick "${MERGE_COMMIT}"; then
|
||||
if [ "$REMOTE_BACKPORT_EXISTS" = true ]; then
|
||||
PUSH_CMD=(git push --force-with-lease origin "${BACKPORT_BRANCH}")
|
||||
git push --force-with-lease origin "${BACKPORT_BRANCH}"
|
||||
else
|
||||
PUSH_CMD=(git push origin "${BACKPORT_BRANCH}")
|
||||
git push origin "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
# A push failure for one target must not abort the loop and
|
||||
# prevent remaining targets from being attempted.
|
||||
if "${PUSH_CMD[@]}"; then
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
else
|
||||
echo "::error::Failed to push ${BACKPORT_BRANCH} for ${TARGET_BRANCH}"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:push-failed "
|
||||
fi
|
||||
|
||||
echo "${BACKPORT_BRANCH}" >> "$CREATED_BRANCHES_FILE"
|
||||
SUCCESS="${SUCCESS}${TARGET_BRANCH}:${BACKPORT_BRANCH} "
|
||||
echo "Successfully created backport branch: ${BACKPORT_BRANCH}"
|
||||
# Return to main (keep the branch, we need it for PR)
|
||||
git checkout main || git checkout -f main
|
||||
git checkout main
|
||||
else
|
||||
# Get conflict info
|
||||
CONFLICTS=$(git diff --name-only --diff-filter=U | tr '\n' ',')
|
||||
git cherry-pick --abort || true
|
||||
git cherry-pick --abort
|
||||
|
||||
echo "::error::Cherry-pick failed due to conflicts"
|
||||
FAILED="${FAILED}${TARGET_BRANCH}:conflicts:${CONFLICTS} "
|
||||
|
||||
# Clean up the failed branch
|
||||
git checkout main || git checkout -f main
|
||||
git branch -D "${BACKPORT_BRANCH}" || true
|
||||
git checkout main
|
||||
git branch -D "${BACKPORT_BRANCH}"
|
||||
fi
|
||||
|
||||
echo "::endgroup::"
|
||||
@@ -401,10 +384,6 @@ jobs:
|
||||
|
||||
**Reason:** Merge conflicts detected during cherry-pick of `${MERGE_COMMIT_SHORT}`
|
||||
|
||||
The auto-backport could not be completed automatically. Please backport
|
||||
manually onto branch `${BACKPORT_BRANCH}` (from `origin/${target}`) and
|
||||
open a PR to `${target}`.
|
||||
|
||||
<details>
|
||||
<summary>📄 Conflicting files</summary>
|
||||
|
||||
@@ -437,37 +416,19 @@ jobs:
|
||||
MERGE_COMMIT=$(jq -r '.pull_request.merge_commit_sha' "$GITHUB_EVENT_PATH")
|
||||
fi
|
||||
|
||||
# Post a comment without letting a single failed `gh pr comment` (e.g.
|
||||
# a locked issue, as happened for PR #13359, or a transient API error)
|
||||
# abort the step under `set -e` and swallow the remaining failures.
|
||||
post_comment() {
|
||||
local body="$1"
|
||||
local context="$2"
|
||||
if ! gh pr comment "${PR_NUMBER}" --body "${body}"; then
|
||||
echo "::warning::Could not comment on PR #${PR_NUMBER} about ${context}. Manual backport required."
|
||||
fi
|
||||
}
|
||||
|
||||
for failure in ${{ steps.backport.outputs.failed }}; do
|
||||
IFS=':' read -r target reason conflicts <<< "${failure}"
|
||||
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
|
||||
if [ "${reason}" = "branch-missing" ]; then
|
||||
post_comment "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist" "missing branch ${target}"
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport failed: Branch \`${target}\` does not exist"
|
||||
|
||||
elif [ "${reason}" = "already-exists" ]; then
|
||||
post_comment "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed." "already-backported ${target}"
|
||||
|
||||
elif [ "${reason}" = "branch-create-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` failed: could not create the backport branch. Please retry or backport manually."
|
||||
|
||||
elif [ "${reason}" = "push-failed" ]; then
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Backport to \`${target}\` cherry-picked cleanly but the push failed. Please retry or push the backport branch manually."
|
||||
gh pr comment "${PR_NUMBER}" --body "@${PR_AUTHOR} Commit \`${MERGE_COMMIT}\` already exists on branch \`${target}\`. No backport needed."
|
||||
|
||||
elif [ "${reason}" = "conflicts" ]; then
|
||||
CONFLICTS_INLINE=$(echo "${conflicts}" | tr ',' ' ')
|
||||
SAFE_TARGET=$(echo "$target" | tr '/' '-')
|
||||
BACKPORT_BRANCH="backport-${PR_NUMBER}-to-${SAFE_TARGET}"
|
||||
PR_URL="${GITHUB_SERVER_URL}/${GITHUB_REPOSITORY}/pull/${PR_NUMBER}"
|
||||
|
||||
export PR_NUMBER PR_URL MERGE_COMMIT target BACKPORT_BRANCH CONFLICTS_INLINE
|
||||
@@ -483,10 +444,10 @@ jobs:
|
||||
CONFLICTS_BLOCK=$(echo "${conflicts}" | tr ',' '\n')
|
||||
MERGE_COMMIT_SHORT="${MERGE_COMMIT:0:7}"
|
||||
|
||||
export target MERGE_COMMIT_SHORT BACKPORT_BRANCH CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${BACKPORT_BRANCH} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
export target MERGE_COMMIT_SHORT CONFLICTS_BLOCK AGENT_PROMPT PR_AUTHOR
|
||||
COMMENT_BODY=$(envsubst '${target} ${MERGE_COMMIT_SHORT} ${CONFLICTS_BLOCK} ${AGENT_PROMPT} ${PR_AUTHOR}' <<<"$COMMENT_BODY_TEMPLATE")
|
||||
|
||||
post_comment "${COMMENT_BODY}" "cherry-pick conflict on ${target} (backport manually onto ${BACKPORT_BRANCH})"
|
||||
gh pr comment "${PR_NUMBER}" --body "${COMMENT_BODY}"
|
||||
fi
|
||||
done
|
||||
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -255,10 +255,9 @@
|
||||
Shifting visually with `top` corrects the appearance without changing the
|
||||
inline-block's bounding box, so button/badge sizes are unaffected. */
|
||||
@utility ppformula-text-center {
|
||||
/* display: inline-block; */
|
||||
/* position: relative; */
|
||||
/* top: 0.1em; */
|
||||
--ppformula-text-center: 1;
|
||||
display: inline-block;
|
||||
position: relative;
|
||||
top: 0.1em;
|
||||
}
|
||||
|
||||
/* Hide native play-button overlay iOS Safari shows when autoplay is blocked
|
||||
|
||||
@@ -276,7 +276,6 @@ export const TestIds = {
|
||||
overlay: 'loading-overlay'
|
||||
},
|
||||
load3d: {
|
||||
categoryMenu: 'load3d-category-menu',
|
||||
recordingDuration: 'load3d-recording-duration'
|
||||
},
|
||||
load3dViewer: {
|
||||
|
||||
@@ -11,47 +11,39 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
get menuButton(): Locator {
|
||||
return this.node.getByTestId(TestIds.load3d.categoryMenu)
|
||||
}
|
||||
|
||||
private get menuPanel(): Locator {
|
||||
return this.node.page().getByRole('dialog')
|
||||
return this.node.getByRole('button', { name: /show menu/i })
|
||||
}
|
||||
|
||||
get recordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Record', exact: true })
|
||||
return this.node.getByRole('button', { name: /start recording/i })
|
||||
}
|
||||
|
||||
get stopRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /stop recording/i })
|
||||
}
|
||||
|
||||
get recordingMenuButton(): Locator {
|
||||
get exportRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /export recording/i })
|
||||
}
|
||||
|
||||
get clearRecordingButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /clear recording/i })
|
||||
}
|
||||
|
||||
get recordingDuration(): Locator {
|
||||
return this.node.getByTestId(TestIds.load3d.recordingDuration)
|
||||
}
|
||||
|
||||
get downloadRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Download Recording' })
|
||||
}
|
||||
|
||||
get startNewRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Start New Recording' })
|
||||
}
|
||||
|
||||
get deleteRecordingMenuItem(): Locator {
|
||||
return this.menuPanel.getByRole('button', { name: 'Delete Recording' })
|
||||
}
|
||||
|
||||
get gridToggleButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /show grid/i })
|
||||
}
|
||||
|
||||
get uploadBackgroundImageButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'BG Image' })
|
||||
return this.node.getByRole('button', { name: /upload background image/i })
|
||||
}
|
||||
|
||||
get removeBackgroundImageButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Remove BG' })
|
||||
return this.node.getByRole('button', { name: /remove background image/i })
|
||||
}
|
||||
|
||||
get panoramaModeButton(): Locator {
|
||||
@@ -62,10 +54,6 @@ export class Load3DHelper {
|
||||
return this.node.locator('input[type="color"]')
|
||||
}
|
||||
|
||||
get exportButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Export', exact: true })
|
||||
}
|
||||
|
||||
get openViewerButton(): Locator {
|
||||
return this.node.getByRole('button', { name: /open in 3d viewer/i })
|
||||
}
|
||||
@@ -75,15 +63,11 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
getMenuCategory(name: string): Locator {
|
||||
return this.menuPanel.getByRole('button', { name, exact: true })
|
||||
return this.node.getByText(name, { exact: true })
|
||||
}
|
||||
|
||||
get gizmoToggleButton(): Locator {
|
||||
// The category chip is also named "Gizmo" once that category is active;
|
||||
// only the toggle carries aria-pressed.
|
||||
return this.node
|
||||
.getByRole('button', { name: 'Gizmo' })
|
||||
.and(this.node.locator('[aria-pressed]'))
|
||||
return this.node.getByRole('button', { name: 'Gizmo' })
|
||||
}
|
||||
|
||||
get gizmoTranslateButton(): Locator {
|
||||
@@ -99,17 +83,13 @@ export class Load3DHelper {
|
||||
}
|
||||
|
||||
get gizmoResetButton(): Locator {
|
||||
return this.node.getByRole('button', { name: 'Reset', exact: true })
|
||||
return this.node.getByRole('button', { name: 'Reset Transform' })
|
||||
}
|
||||
|
||||
async openMenu(): Promise<void> {
|
||||
await this.menuButton.click()
|
||||
}
|
||||
|
||||
async openRecordingMenu(): Promise<void> {
|
||||
await this.recordingMenuButton.click()
|
||||
}
|
||||
|
||||
async openGizmoCategory(): Promise<void> {
|
||||
await this.openMenu()
|
||||
await this.getMenuCategory('Gizmo').click()
|
||||
|
||||
@@ -47,12 +47,10 @@ test.describe('Load3D', () => {
|
||||
await load3d.openMenu()
|
||||
|
||||
await expect(load3d.getMenuCategory('Scene')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('3D Model')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Model')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Camera')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Light')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('HDRI')).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Gizmo')).toBeVisible()
|
||||
await expect(load3d.exportButton).toBeVisible()
|
||||
await expect(load3d.getMenuCategory('Export')).toBeVisible()
|
||||
|
||||
await expect(load3d.node).toHaveScreenshot(
|
||||
'load3d-controls-menu-open.png',
|
||||
@@ -255,7 +253,7 @@ test.describe('Load3D', () => {
|
||||
}
|
||||
)
|
||||
|
||||
test('Recording controls collapse into a duration chip with a menu after a recording', async ({
|
||||
test('Recording controls show stop/export/clear buttons after a recording', async ({
|
||||
comfyPage,
|
||||
load3d
|
||||
}) => {
|
||||
@@ -267,25 +265,20 @@ test.describe('Load3D', () => {
|
||||
await expect(load3d.stopRecordingButton).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Stop recording surfaces the duration chip with a 1s duration', async () => {
|
||||
await test.step('Stop recording surfaces export/clear controls and a 1s duration', async () => {
|
||||
// Record for 1s wall-clock so the duration display settles on 00:01.
|
||||
await comfyPage.delay(1000)
|
||||
await load3d.stopRecordingButton.click()
|
||||
await expect(load3d.recordingMenuButton).toBeVisible()
|
||||
await expect(load3d.recordingMenuButton).toHaveText('00:01')
|
||||
})
|
||||
|
||||
await test.step('Chip menu offers download, re-record and delete actions', async () => {
|
||||
await load3d.openRecordingMenu()
|
||||
await expect(load3d.downloadRecordingMenuItem).toBeVisible()
|
||||
await expect(load3d.startNewRecordingMenuItem).toBeVisible()
|
||||
await expect(load3d.deleteRecordingMenuItem).toBeVisible()
|
||||
})
|
||||
|
||||
await test.step('Deleting the recording restores the record button', async () => {
|
||||
await load3d.deleteRecordingMenuItem.click()
|
||||
await expect(load3d.recordingMenuButton).toHaveCount(0)
|
||||
await expect(load3d.recordingButton).toBeVisible()
|
||||
await expect(load3d.exportRecordingButton).toBeVisible()
|
||||
await expect(load3d.clearRecordingButton).toBeVisible()
|
||||
await expect(load3d.recordingDuration).toHaveText('00:01')
|
||||
})
|
||||
|
||||
await test.step('Clear recording removes export and clear controls', async () => {
|
||||
await load3d.clearRecordingButton.click()
|
||||
await expect(load3d.exportRecordingButton).toHaveCount(0)
|
||||
await expect(load3d.clearRecordingButton).toHaveCount(0)
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 109 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 126 KiB After Width: | Height: | Size: 148 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 40 KiB After Width: | Height: | Size: 44 KiB |
@@ -476,37 +476,6 @@ test.describe('Minimap', { tag: '@canvas' }, () => {
|
||||
})
|
||||
.toBe(true)
|
||||
})
|
||||
|
||||
test(
|
||||
'Closing minimap after subgraph navigation keeps Vue render in sync',
|
||||
{ tag: '@vue-nodes' },
|
||||
async ({ comfyPage }) => {
|
||||
await comfyPage.workflow.loadWorkflow('subgraphs/basic-subgraph')
|
||||
|
||||
const subgraphNodeId = await comfyPage.subgraph.findSubgraphNodeId()
|
||||
|
||||
// Round-trip layers Vue's onNodeAdded wrapper on top of the minimap's.
|
||||
await comfyPage.vueNodes.enterSubgraph(subgraphNodeId)
|
||||
await comfyPage.subgraph.exitViaBreadcrumb()
|
||||
|
||||
// Minimap unmount must not clobber the Vue wrapper layered above it.
|
||||
await comfyPage.page
|
||||
.getByTestId(TestIds.canvas.closeMinimapButton)
|
||||
.click()
|
||||
|
||||
const subgraphFixture =
|
||||
await comfyPage.vueNodes.getFixtureByTitle('New Subgraph')
|
||||
await comfyPage.contextMenu.openForVueNode(subgraphFixture.header)
|
||||
await comfyPage.contextMenu.clickMenuItemExact('Unpack Subgraph')
|
||||
await comfyPage.contextMenu.waitForHidden()
|
||||
|
||||
await expect.poll(() => comfyPage.nodeOps.getGraphNodesCount()).toBe(2)
|
||||
await expect.poll(() => comfyPage.vueNodes.getNodeCount()).toBe(2)
|
||||
await expect(
|
||||
comfyPage.vueNodes.getNodeLocator(subgraphNodeId)
|
||||
).toHaveCount(0)
|
||||
}
|
||||
)
|
||||
})
|
||||
|
||||
test.describe('Minimap mobile', { tag: ['@mobile', '@canvas'] }, () => {
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@comfyorg/comfyui-frontend",
|
||||
"version": "1.48.2",
|
||||
"version": "1.48.0",
|
||||
"private": true,
|
||||
"description": "Official front-end implementation of ComfyUI",
|
||||
"homepage": "https://comfy.org",
|
||||
@@ -114,7 +114,6 @@
|
||||
"jsonata": "catalog:",
|
||||
"loglevel": "^1.9.2",
|
||||
"marked": "^15.0.11",
|
||||
"minisearch": "catalog:",
|
||||
"pinia": "catalog:",
|
||||
"posthog-js": "catalog:",
|
||||
"primeicons": "catalog:",
|
||||
|
||||
19
pnpm-lock.yaml
generated
19
pnpm-lock.yaml
generated
@@ -282,9 +282,6 @@ catalogs:
|
||||
markdown-table:
|
||||
specifier: ^3.0.4
|
||||
version: 3.0.4
|
||||
minisearch:
|
||||
specifier: ^7.2.0
|
||||
version: 7.2.0
|
||||
mixpanel-browser:
|
||||
specifier: ^2.71.0
|
||||
version: 2.71.0
|
||||
@@ -594,9 +591,6 @@ importers:
|
||||
marked:
|
||||
specifier: ^15.0.11
|
||||
version: 15.0.11
|
||||
minisearch:
|
||||
specifier: 'catalog:'
|
||||
version: 7.2.0
|
||||
pinia:
|
||||
specifier: 'catalog:'
|
||||
version: 3.0.4(typescript@5.9.3)(vue@3.5.34(typescript@5.9.3))
|
||||
@@ -7030,9 +7024,6 @@ packages:
|
||||
resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
|
||||
minisearch@7.2.0:
|
||||
resolution: {integrity: sha512-dqT2XBYUOZOiC5t2HRnwADjhNS2cecp9u+TJRiJ1Qp/f5qjkeT5APcGPjHw+bz89Ms8Jp+cG4AlE+QZ/QnDglg==}
|
||||
|
||||
mitt@3.0.1:
|
||||
resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==}
|
||||
|
||||
@@ -8893,8 +8884,8 @@ packages:
|
||||
vue-component-type-helpers@3.3.2:
|
||||
resolution: {integrity: sha512-l4Z2Y34m7nFMlx8vrslJaVtXxUpzgDMSESC7TakG/c5kwjYT/do+E0NcT2/vWDzaoIhsShg/2OKwX7Q4nbzC0g==}
|
||||
|
||||
vue-component-type-helpers@3.3.6:
|
||||
resolution: {integrity: sha512-FkljacAwJ9BUoSUdpFe3VDy0sGigNlTH9+2zcXUWmZOjN8swiCkl3t48wOJun0OsUd2cEIda1l04tsxMiKIIrQ==}
|
||||
vue-component-type-helpers@3.3.5:
|
||||
resolution: {integrity: sha512-Fe1jyPJoUGpJOYKOri44jduR7My4yYINOMJISuMAbmrs+L5LbIDUc8NTWZYY3EJLK0yPLuCmcd5zoCsE4k2/KA==}
|
||||
|
||||
vue-demi@0.14.10:
|
||||
resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==}
|
||||
@@ -11677,7 +11668,7 @@ snapshots:
|
||||
storybook: 10.2.10(@testing-library/dom@10.4.1)(prettier@3.7.4)(react-dom@19.2.4(react@19.2.4))(react@19.2.4)
|
||||
type-fest: 2.19.0
|
||||
vue: 3.5.34(typescript@5.9.3)
|
||||
vue-component-type-helpers: 3.3.6
|
||||
vue-component-type-helpers: 3.3.5
|
||||
|
||||
'@swc/helpers@0.5.21':
|
||||
dependencies:
|
||||
@@ -15826,8 +15817,6 @@ snapshots:
|
||||
|
||||
minipass@7.1.3: {}
|
||||
|
||||
minisearch@7.2.0: {}
|
||||
|
||||
mitt@3.0.1: {}
|
||||
|
||||
mixpanel-browser@2.71.0:
|
||||
@@ -18149,7 +18138,7 @@ snapshots:
|
||||
|
||||
vue-component-type-helpers@3.3.2: {}
|
||||
|
||||
vue-component-type-helpers@3.3.6: {}
|
||||
vue-component-type-helpers@3.3.5: {}
|
||||
|
||||
vue-demi@0.14.10(vue@3.5.34(typescript@5.9.3)):
|
||||
dependencies:
|
||||
|
||||
@@ -103,7 +103,6 @@ catalog:
|
||||
lenis: ^1.3.21
|
||||
lint-staged: ^16.2.7
|
||||
markdown-table: ^3.0.4
|
||||
minisearch: ^7.2.0
|
||||
mixpanel-browser: ^2.71.0
|
||||
monocart-coverage-reports: ^2.12.9
|
||||
oxfmt: ^0.54.0
|
||||
|
||||
@@ -4,67 +4,37 @@
|
||||
data-testid="bounding-boxes"
|
||||
@pointerdown.stop
|
||||
>
|
||||
<div class="flex flex-col">
|
||||
<div
|
||||
class="flex h-9 items-center gap-1 rounded-t-sm border border-b-0 border-component-node-border bg-component-node-widget-background px-2"
|
||||
>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:aria-pressed="grid"
|
||||
:class="
|
||||
cn(
|
||||
actionBtnClass,
|
||||
grid && 'bg-component-node-widget-background-selected'
|
||||
)
|
||||
"
|
||||
@click="grid = !grid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span>{{ $t('boundingBoxes.grid') }}</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="textonly"
|
||||
size="unset"
|
||||
:class="cn(actionBtnClass, 'ml-auto')"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2] size-4" />
|
||||
<span>{{ $t('boundingBoxes.clearAll') }}</span>
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-b-sm border border-t-0 border-component-node-border bg-base-background"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm text-node-component-slot-text outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
ref="canvasContainer"
|
||||
class="relative w-full shrink-0 overflow-hidden rounded-sm border border-component-node-border bg-node-component-surface"
|
||||
:style="canvasStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="canvasEl"
|
||||
tabindex="0"
|
||||
class="absolute inset-0 size-full rounded-sm outline-none"
|
||||
:style="{ cursor: canvasCursor }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onCanvasPointerMove"
|
||||
@pointerup="onDocPointerUp"
|
||||
@pointercancel="onDocPointerUp"
|
||||
@pointerleave="onPointerLeave"
|
||||
@lostpointercapture="onDocPointerUp"
|
||||
@dblclick="onDoubleClick"
|
||||
@keydown="onCanvasKeyDown"
|
||||
@focus="focused = true"
|
||||
@blur="focused = false"
|
||||
/>
|
||||
<textarea
|
||||
v-if="inlineEditor"
|
||||
ref="inlineEditorEl"
|
||||
v-model="inlineEditor.value"
|
||||
class="absolute box-border resize-none rounded-sm border-2 bg-black/90 p-1 font-mono text-xs text-white outline-none"
|
||||
:style="inlineEditor.style"
|
||||
data-capture-wheel="true"
|
||||
@keydown.stop="onInlineKeyDown"
|
||||
@blur="commitInlineEditor"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -152,6 +122,16 @@
|
||||
<div v-else-if="hasRegions" class="text-node-text-muted px-1 text-xs">
|
||||
{{ $t('boundingBoxes.clickRegionToEdit') }}
|
||||
</div>
|
||||
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="md"
|
||||
class="gap-2 rounded-lg border border-component-node-border bg-component-node-background text-xs text-muted-foreground hover:text-base-foreground"
|
||||
@click="clearAll"
|
||||
>
|
||||
<i class="icon-[lucide--undo-2]" />
|
||||
{{ $t('boundingBoxes.clearAll') }}
|
||||
</Button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -167,9 +147,6 @@ import { useBoundingBoxes } from '@/composables/boundingBoxes/useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import type { NodeId } from '@/types/nodeId'
|
||||
|
||||
const actionBtnClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-component-node-widget-background-hovered'
|
||||
|
||||
const { nodeId } = defineProps<{ nodeId: NodeId }>()
|
||||
const modelValue = defineModel<BoundingBox[]>({ default: () => [] })
|
||||
|
||||
@@ -195,8 +172,7 @@ const {
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState,
|
||||
grid
|
||||
syncState
|
||||
} = useBoundingBoxes(nodeId, {
|
||||
canvasEl,
|
||||
canvasContainer,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import type { MenuItem } from 'primevue/menuitem'
|
||||
import {
|
||||
DropdownMenuArrow,
|
||||
@@ -11,15 +12,21 @@ import { computed, ref, toValue } from 'vue'
|
||||
|
||||
import DropdownItem from '@/components/common/DropdownItem.vue'
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { useModalLiftedZIndex } from '@/composables/useModalLiftedZIndex'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import type { ButtonVariants } from '../ui/button/button.variants'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
const {
|
||||
itemClass: itemProp,
|
||||
contentClass: contentProp,
|
||||
modal = true
|
||||
} = defineProps<{
|
||||
entries?: MenuItem[]
|
||||
icon?: string
|
||||
to?: string | HTMLElement
|
||||
@@ -27,6 +34,7 @@ const { itemClass: itemProp, contentClass: contentProp } = defineProps<{
|
||||
contentClass?: string
|
||||
buttonSize?: ButtonVariants['size']
|
||||
buttonClass?: string
|
||||
modal?: boolean
|
||||
}>()
|
||||
|
||||
const itemClass = computed(() =>
|
||||
@@ -43,12 +51,19 @@ const contentClass = computed(() =>
|
||||
)
|
||||
)
|
||||
|
||||
// Body-portaled content keeps its static z-1700 unless a dialog that joined
|
||||
// @primeuix's auto-incrementing 'modal' counter is open above it; then lift
|
||||
// past that dialog so the menu isn't hidden behind it.
|
||||
const open = ref(false)
|
||||
const contentStyle = useModalLiftedZIndex(open)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DropdownMenuRoot v-model:open="open">
|
||||
<DropdownMenuRoot v-model:open="open" :modal>
|
||||
<DropdownMenuTrigger as-child>
|
||||
<slot name="button">
|
||||
<Button :size="buttonSize ?? 'icon'" :class="buttonClass">
|
||||
|
||||
40
src/components/common/SelectionBar.vue
Normal file
40
src/components/common/SelectionBar.vue
Normal file
@@ -0,0 +1,40 @@
|
||||
<template>
|
||||
<div class="relative mx-2">
|
||||
<div
|
||||
class="absolute bottom-6 left-1/2 z-40 flex w-full max-w-78 -translate-x-1/2 items-center gap-2 rounded-lg bg-base-foreground p-2 text-base-background shadow-interface"
|
||||
>
|
||||
<Button
|
||||
v-tooltip.top="{ value: deselectLabel, showDelay: 300 }"
|
||||
variant="inverted"
|
||||
size="icon-lg"
|
||||
type="button"
|
||||
:aria-label="deselectLabel"
|
||||
class="rounded-lg hover:bg-base-background/10"
|
||||
@click="emit('deselect')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
</Button>
|
||||
<span class="pr-6 text-sm font-bold whitespace-nowrap tabular-nums">
|
||||
{{ label }}
|
||||
</span>
|
||||
<div class="ml-auto flex shrink-0 items-center gap-1">
|
||||
<slot />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
defineProps<{
|
||||
/** The "N selected" text; the caller formats it (pluralization, wording). */
|
||||
label: string
|
||||
/** Accessible label + tooltip for the deselect button. */
|
||||
deselectLabel: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
deselect: []
|
||||
}>()
|
||||
</script>
|
||||
@@ -14,7 +14,7 @@
|
||||
class="p-1 text-amber-400"
|
||||
>
|
||||
<template #icon>
|
||||
<i class="icon-[lucide--component]" />
|
||||
<i class="icon-[lucide--coins]" />
|
||||
</template>
|
||||
</Tag>
|
||||
<div :class="textClass">
|
||||
|
||||
@@ -97,7 +97,7 @@
|
||||
<!-- Sort Options -->
|
||||
<div>
|
||||
<SingleSelect
|
||||
v-model="sortSelection"
|
||||
v-model="sortBy"
|
||||
:label="$t('templateWorkflows.sorting', 'Sort by')"
|
||||
:options="sortOptions"
|
||||
:content-style="selectContentStyle"
|
||||
@@ -556,8 +556,7 @@ const {
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedRunsOn,
|
||||
sortSelection,
|
||||
hasActiveQuery,
|
||||
sortBy,
|
||||
activeModels,
|
||||
activeUseCases,
|
||||
filteredTemplates,
|
||||
@@ -566,13 +565,14 @@ const {
|
||||
availableRunsOn,
|
||||
filteredCount,
|
||||
totalCount,
|
||||
resetFilters
|
||||
resetFilters,
|
||||
loadFuseOptions
|
||||
} = useTemplateFiltering(navigationFilteredTemplates)
|
||||
|
||||
/**
|
||||
* Raw search input bound to the search box. The actual `searchQuery` consumed
|
||||
* by the filtering composable is only updated via `applySearchQuery` after the
|
||||
* debounce settles, keeping search/grid re-renders off the keystroke critical path.
|
||||
* debounce settles, keeping Fuse/grid re-renders off the keystroke critical path.
|
||||
*/
|
||||
const searchInput = ref(searchQuery.value)
|
||||
|
||||
@@ -595,13 +595,15 @@ watch(searchQuery, (value) => {
|
||||
*/
|
||||
const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
const isPopularNav = selectedNavItem.value === 'popular'
|
||||
const isPopularSort = sortSelection.value === 'popular'
|
||||
const isPopularSort = sortBy.value === 'popular'
|
||||
|
||||
if (source === 'nav') {
|
||||
if (isPopularNav && !isPopularSort) {
|
||||
sortSelection.value = 'popular'
|
||||
// When navigating to 'Popular' category, automatically set sort to 'Popular'.
|
||||
sortBy.value = 'popular'
|
||||
} else if (!isPopularNav && isPopularSort) {
|
||||
sortSelection.value = 'default'
|
||||
// When navigating away from 'Popular' category while sort is 'Popular', reset sort to default.
|
||||
sortBy.value = 'default'
|
||||
}
|
||||
} else if (source === 'sort') {
|
||||
// When sort is changed away from 'Popular' while in the 'Popular' category,
|
||||
@@ -614,7 +616,7 @@ const coordinateNavAndSort = (source: 'nav' | 'sort') => {
|
||||
|
||||
// Watch for changes from the two sources ('nav' and 'sort') and trigger the coordinator.
|
||||
watch(selectedNavItem, () => coordinateNavAndSort('nav'))
|
||||
watch(sortSelection, () => coordinateNavAndSort('sort'))
|
||||
watch(sortBy, () => coordinateNavAndSort('sort'))
|
||||
|
||||
// Convert between string array and object array for MultiSelect component
|
||||
// Only show selected items that exist in the current scope
|
||||
@@ -724,15 +726,8 @@ const runsOnFilterLabel = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
// Sort options
|
||||
const sortOptions = computed(() => [
|
||||
...(hasActiveQuery.value
|
||||
? [
|
||||
{
|
||||
name: t('templateWorkflows.sort.relevance', 'Relevance'),
|
||||
value: 'relevance'
|
||||
}
|
||||
]
|
||||
: []),
|
||||
{
|
||||
name: t('templateWorkflows.sort.default', 'Default'),
|
||||
value: 'default'
|
||||
@@ -794,7 +789,7 @@ watch(
|
||||
[
|
||||
filteredTemplates,
|
||||
selectedNavItem,
|
||||
sortSelection,
|
||||
sortBy,
|
||||
selectedModels,
|
||||
selectedUseCases,
|
||||
selectedRunsOn
|
||||
@@ -844,7 +839,8 @@ const { isLoading } = useAsyncState(
|
||||
async () => {
|
||||
await Promise.all([
|
||||
loadTemplates(),
|
||||
workflowTemplatesStore.loadWorkflowTemplates()
|
||||
workflowTemplatesStore.loadWorkflowTemplates(),
|
||||
loadFuseOptions()
|
||||
])
|
||||
return true
|
||||
},
|
||||
|
||||
@@ -86,7 +86,7 @@
|
||||
@max-reached="showCeilingWarning = true"
|
||||
>
|
||||
<template #prefix>
|
||||
<i class="icon-[lucide--component] size-4 shrink-0 text-gold-500" />
|
||||
<i class="icon-[lucide--coins] size-4 shrink-0 text-gold-500" />
|
||||
</template>
|
||||
</FormattedNumberStepper>
|
||||
</div>
|
||||
@@ -98,7 +98,7 @@
|
||||
v-if="isBelowMin"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-red-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.minRequired', {
|
||||
credits: formatNumber(usdToCredits(MIN_AMOUNT))
|
||||
@@ -109,7 +109,7 @@
|
||||
v-if="showCeilingWarning"
|
||||
class="m-0 flex items-center justify-center gap-1 px-8 pt-4 text-center text-sm text-gold-500"
|
||||
>
|
||||
<i class="icon-[lucide--component] size-4" />
|
||||
<i class="icon-[lucide--coins] size-4" />
|
||||
{{
|
||||
$t('credits.topUp.maxAllowed', {
|
||||
credits: formatNumber(usdToCredits(MAX_AMOUNT))
|
||||
|
||||
@@ -12,7 +12,7 @@ const PRIMEVUE_OVERLAY_SELECTORS =
|
||||
// dismiss itself. These selectors cover the portaled roots so we can treat
|
||||
// interactions on them as inside.
|
||||
const REKA_PORTAL_SELECTORS =
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"]'
|
||||
'[data-reka-popper-content-wrapper], [data-reka-dialog-content], [data-reka-menu-content], [data-reka-context-menu-content], [role="dialog"], [role="menu"], [role="listbox"], [role="tooltip"], [aria-haspopup="menu"], [aria-haspopup="dialog"], [aria-haspopup="listbox"]'
|
||||
|
||||
const OUTSIDE_LAYER_SELECTORS = `${PRIMEVUE_OVERLAY_SELECTORS}, ${REKA_PORTAL_SELECTORS}`
|
||||
|
||||
|
||||
@@ -128,9 +128,9 @@ function renderLoad3D(options: RenderOptions = {}) {
|
||||
name: 'AnimationControls',
|
||||
template: '<div data-testid="animation-controls" />'
|
||||
},
|
||||
RecordMenuControl: {
|
||||
name: 'RecordMenuControl',
|
||||
template: '<div data-testid="record-menu-control" />'
|
||||
RecordingControls: {
|
||||
name: 'RecordingControls',
|
||||
template: '<div data-testid="recording-controls" />'
|
||||
},
|
||||
ViewerControls: {
|
||||
name: 'ViewerControls',
|
||||
@@ -232,16 +232,14 @@ describe('Load3D', () => {
|
||||
})
|
||||
|
||||
describe('recording controls', () => {
|
||||
it('renders the record control in regular (non-preview) mode', () => {
|
||||
it('renders RecordingControls in regular (non-preview) mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(false) } })
|
||||
expect(screen.getByTestId('record-menu-control')).toBeInTheDocument()
|
||||
expect(screen.getByTestId('recording-controls')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the record control in preview mode', () => {
|
||||
it('hides RecordingControls in preview mode', () => {
|
||||
renderLoad3D({ stateOverrides: { isPreview: ref(true) } })
|
||||
expect(
|
||||
screen.queryByTestId('record-menu-control')
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.queryByTestId('recording-controls')).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -15,39 +15,25 @@
|
||||
:is-preview="isPreview"
|
||||
/>
|
||||
<div class="pointer-events-none absolute top-0 left-0 size-full">
|
||||
<Load3DMenuBar
|
||||
<Load3DControls
|
||||
v-model:scene-config="sceneConfig"
|
||||
v-model:model-config="modelConfig"
|
||||
v-model:camera-config="cameraConfig"
|
||||
v-model:light-config="lightConfig"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
:can-use-gizmo="canUseGizmo"
|
||||
:can-use-lighting="canUseLighting"
|
||||
:can-export="canExport"
|
||||
:can-use-hdri="canUseHdri"
|
||||
:can-use-background-image="canUseBackgroundImage"
|
||||
:can-fit-to-viewer="canFitToViewer"
|
||||
:can-center-camera-on-model="canCenterCameraOnModel"
|
||||
:node="node as LGraphNode"
|
||||
:enable-viewer="enable3DViewer"
|
||||
:can-use-recording="canUseRecording && !isPreview"
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
:source-format="sourceFormat"
|
||||
@update-background-image="handleBackgroundImageUpdate"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@export-model="handleExportModel"
|
||||
@fit-to-viewer="handleFitToViewer"
|
||||
@center-camera="handleCenterCameraOnModel"
|
||||
@update-hdri-file="handleHDRIFileUpdate"
|
||||
@toggle-gizmo="handleToggleGizmo"
|
||||
@set-gizmo-mode="handleSetGizmoMode"
|
||||
@reset-gizmo-transform="handleResetGizmoTransform"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@clear-recording="handleClearRecording"
|
||||
/>
|
||||
<AnimationControls
|
||||
v-if="animations && animations.length > 0"
|
||||
@@ -60,6 +46,59 @@
|
||||
@seek="handleSeek"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
class="pointer-events-auto absolute top-12 right-2 z-20 flex flex-col gap-2"
|
||||
>
|
||||
<div
|
||||
v-if="canFitToViewer || canCenterCameraOnModel"
|
||||
class="flex flex-col rounded-lg bg-backdrop/30"
|
||||
>
|
||||
<Button
|
||||
v-if="canFitToViewer"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.fitToViewer'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.fitToViewer')"
|
||||
@click="handleFitToViewer"
|
||||
>
|
||||
<i class="pi pi-window-maximize text-lg text-base-foreground" />
|
||||
</Button>
|
||||
<Button
|
||||
v-if="canCenterCameraOnModel"
|
||||
v-tooltip.left="{
|
||||
value: $t('load3d.centerCameraOnModel'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.centerCameraOnModel')"
|
||||
@click="handleCenterCameraOnModel"
|
||||
>
|
||||
<i class="pi pi-compass text-lg text-base-foreground" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ViewerControls
|
||||
v-if="enable3DViewer && node"
|
||||
:node="node as LGraphNode"
|
||||
/>
|
||||
|
||||
<RecordingControls
|
||||
v-if="canUseRecording && !isPreview"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
@start-recording="handleStartRecording"
|
||||
@stop-recording="handleStopRecording"
|
||||
@export-recording="handleExportRecording"
|
||||
@clear-recording="handleClearRecording"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -67,9 +106,12 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.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 Button from '@/components/ui/button/Button.vue'
|
||||
import { useLoad3d } from '@/composables/useLoad3d'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
@@ -150,11 +192,11 @@ const {
|
||||
handleHDRIFileUpdate,
|
||||
handleExportModel,
|
||||
handleModelDrop,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
handleToggleGizmo,
|
||||
handleSetGizmoMode,
|
||||
handleResetGizmoTransform,
|
||||
handleFitToViewer,
|
||||
handleCenterCameraOnModel,
|
||||
cleanup
|
||||
} = useLoad3d(node as Ref<LGraphNode | null>)
|
||||
|
||||
|
||||
@@ -1,242 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import type { ComponentProps } from 'vue-component-type-helpers'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import Load3DMenuBar from '@/components/load3d/Load3DMenuBar.vue'
|
||||
import type {
|
||||
CameraConfig,
|
||||
LightConfig,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeSceneConfig(): SceneConfig {
|
||||
return {
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
}
|
||||
}
|
||||
|
||||
function makeModelConfig(): ModelConfig {
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled: false,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function makeCameraConfig(): CameraConfig {
|
||||
return { cameraType: 'perspective', fov: 75 }
|
||||
}
|
||||
|
||||
function makeLightConfig(): LightConfig {
|
||||
return {
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type RenderProps = Partial<ComponentProps<typeof Load3DMenuBar>>
|
||||
|
||||
function renderMenuBar(overrides: RenderProps = {}) {
|
||||
const result = render(Load3DMenuBar, {
|
||||
props: {
|
||||
sceneConfig: makeSceneConfig(),
|
||||
modelConfig: makeModelConfig(),
|
||||
cameraConfig: makeCameraConfig(),
|
||||
lightConfig: makeLightConfig(),
|
||||
...overrides
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
async function selectCategory(
|
||||
user: ReturnType<typeof userEvent.setup>,
|
||||
label: string
|
||||
) {
|
||||
await openCategoryMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: label }))
|
||||
}
|
||||
|
||||
async function openCategoryMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(screen.getByRole('button', { name: /Scene/ }))
|
||||
}
|
||||
|
||||
describe('Load3DMenuBar', () => {
|
||||
it('shows scene controls by default', () => {
|
||||
renderMenuBar()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Show grid' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles showGrid on the bound config when the grid button is clicked', async () => {
|
||||
const sceneConfig = makeSceneConfig()
|
||||
const { user } = renderMenuBar({ sceneConfig })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show grid' }))
|
||||
|
||||
expect(sceneConfig.showGrid).toBe(false)
|
||||
})
|
||||
|
||||
it('emits fitToViewer when the fit button is clicked', async () => {
|
||||
const onFitToViewer = vi.fn()
|
||||
const { user } = renderMenuBar({ onFitToViewer })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Fit to Viewer' }))
|
||||
|
||||
expect(onFitToViewer).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('emits centerCamera when the center button is clicked', async () => {
|
||||
const onCenterCamera = vi.fn()
|
||||
const { user } = renderMenuBar({ onCenterCamera })
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Center Camera on Model' })
|
||||
)
|
||||
|
||||
expect(onCenterCamera).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('hides the center button when canCenterCameraOnModel is false', () => {
|
||||
renderMenuBar({ canCenterCameraOnModel: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Center Camera on Model' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles the gizmo and reveals the mode controls inline', async () => {
|
||||
const onToggleGizmo = vi.fn()
|
||||
const onSetGizmoMode = vi.fn()
|
||||
const { user } = renderMenuBar({ onToggleGizmo, onSetGizmoMode })
|
||||
|
||||
await selectCategory(user, 'Gizmo')
|
||||
// The chip and the enable toggle share the 'Gizmo' name; click the toggle.
|
||||
const gizmoButtons = screen.getAllByRole('button', { name: 'Gizmo' })
|
||||
await user.click(gizmoButtons[gizmoButtons.length - 1])
|
||||
expect(onToggleGizmo).toHaveBeenCalledWith(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate' }))
|
||||
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
})
|
||||
|
||||
it('shows the hdri upload inline without an extra popover', async () => {
|
||||
const { user } = renderMenuBar()
|
||||
|
||||
await selectCategory(user, 'HDRI')
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('forwards removeHdri as updateHdriFile(null) when a file is loaded', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const lightConfig = makeLightConfig()
|
||||
lightConfig.hdri = {
|
||||
enabled: true,
|
||||
hdriPath: 'env.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 1
|
||||
}
|
||||
const { user } = renderMenuBar({ lightConfig, onUpdateHdriFile })
|
||||
|
||||
await selectCategory(user, 'HDRI')
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('emits startRecording when the record button is clicked', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const { user } = renderMenuBar({ onStartRecording })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Record' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('forwards exportRecording from the recording menu once a recording exists', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const { user } = renderMenuBar({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
onExportRecording
|
||||
})
|
||||
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
|
||||
)
|
||||
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('omits the gizmo category when canUseGizmo is false', async () => {
|
||||
const { user } = renderMenuBar({ canUseGizmo: false })
|
||||
await openCategoryMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Gizmo' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('switches to the camera category and shows its controls', async () => {
|
||||
const { user } = renderMenuBar()
|
||||
await openCategoryMenu(user)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Camera' }))
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Perspective' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show grid' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('omits the light category when canUseLighting is false', async () => {
|
||||
const { user } = renderMenuBar({ canUseLighting: false })
|
||||
await openCategoryMenu(user)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Light' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides scene controls when sceneConfig is undefined', () => {
|
||||
renderMenuBar({ sceneConfig: undefined })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Show grid' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,337 +0,0 @@
|
||||
<template>
|
||||
<div class="pointer-events-none absolute inset-0 flex flex-col">
|
||||
<div
|
||||
ref="topBarRef"
|
||||
class="pointer-events-auto flex h-10 items-center gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<Popover v-model:open="catMenuOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
:class="chipClass"
|
||||
type="button"
|
||||
data-testid="load3d-category-menu"
|
||||
>
|
||||
{{ activeLabel }}
|
||||
<i class="icon-[lucide--chevron-down] size-4 opacity-70" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="c in categoryDefs"
|
||||
:key="c.key"
|
||||
type="button"
|
||||
:class="
|
||||
cn(
|
||||
rowClass,
|
||||
activeCategory === c.key && 'bg-button-active-surface'
|
||||
)
|
||||
"
|
||||
@click="selectCategory(c.key)"
|
||||
>
|
||||
{{ c.label }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<div class="mx-1 h-5 w-px shrink-0 bg-interface-menu-stroke" />
|
||||
|
||||
<SceneMenuGroup
|
||||
v-if="activeCategory === 'scene' && sceneConfig"
|
||||
v-model:config="sceneConfig"
|
||||
v-model:fov="cameraFov"
|
||||
:compact
|
||||
:can-use-background-image="canUseBackgroundImage"
|
||||
:hdri-active="hdriActive"
|
||||
@update-background-image="emit('updateBackgroundImage', $event)"
|
||||
/>
|
||||
<ModelMenuGroup
|
||||
v-else-if="activeCategory === 'model' && modelConfig"
|
||||
v-model:config="modelConfig"
|
||||
:compact
|
||||
:material-modes="materialModes"
|
||||
:has-skeleton="hasSkeleton"
|
||||
/>
|
||||
<CameraMenuGroup
|
||||
v-else-if="activeCategory === 'camera' && cameraConfig"
|
||||
v-model:config="cameraConfig"
|
||||
:compact
|
||||
/>
|
||||
<LightMenuGroup
|
||||
v-else-if="activeCategory === 'light' && lightConfig && modelConfig"
|
||||
v-model:config="lightConfig"
|
||||
:compact
|
||||
:is-original-material="isOriginalMaterial"
|
||||
/>
|
||||
<HdriMenuGroup
|
||||
v-else-if="activeCategory === 'hdri' && lightConfig"
|
||||
v-model:config="lightConfig"
|
||||
:compact
|
||||
:scene-has-image="sceneHasImage"
|
||||
@update-hdri-file="emit('updateHdriFile', $event)"
|
||||
/>
|
||||
<GizmoMenuGroup
|
||||
v-else-if="activeCategory === 'gizmo' && modelConfig"
|
||||
v-model:config="modelConfig"
|
||||
:compact
|
||||
@toggle-gizmo="emit('toggleGizmo', $event)"
|
||||
@set-gizmo-mode="emit('setGizmoMode', $event)"
|
||||
@reset-gizmo-transform="emit('resetGizmoTransform')"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div
|
||||
:class="
|
||||
cn('flex-1', isRecording && 'border-2 border-node-component-executing')
|
||||
"
|
||||
/>
|
||||
|
||||
<div
|
||||
class="pointer-events-auto flex h-10 items-center justify-between gap-1 bg-interface-menu-surface px-2"
|
||||
@wheel.stop
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<RecordMenuControl
|
||||
v-if="canUseRecording"
|
||||
v-model:is-recording="isRecording"
|
||||
v-model:has-recording="hasRecording"
|
||||
v-model:recording-duration="recordingDuration"
|
||||
:compact
|
||||
@start-recording="emit('startRecording')"
|
||||
@stop-recording="emit('stopRecording')"
|
||||
@export-recording="emit('exportRecording')"
|
||||
@clear-recording="emit('clearRecording')"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex items-center gap-1">
|
||||
<ViewerControls
|
||||
v-if="enableViewer && node"
|
||||
:node="node as LGraphNode"
|
||||
/>
|
||||
<button
|
||||
v-if="canFitToViewer"
|
||||
v-tooltip.top="tip(t('load3d.fitToViewer'))"
|
||||
:class="iconBtnClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.fitToViewer')"
|
||||
@click="emit('fitToViewer')"
|
||||
>
|
||||
<i class="icon-[lucide--scan] size-4" />
|
||||
</button>
|
||||
<button
|
||||
v-if="canCenterCameraOnModel"
|
||||
v-tooltip.top="tip(t('load3d.centerCameraOnModel'))"
|
||||
:class="iconBtnClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.centerCameraOnModel')"
|
||||
@click="emit('centerCamera')"
|
||||
>
|
||||
<i class="icon-[lucide--crosshair] size-4" />
|
||||
</button>
|
||||
<Popover v-if="canExport" v-model:open="exportOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.top="tip(t('load3d.export'))"
|
||||
:class="iconBtnClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.export')"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="top"
|
||||
align="end"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="format in exportFormats"
|
||||
:key="format.value"
|
||||
type="button"
|
||||
:class="rowClass"
|
||||
@click="onExport(format.value)"
|
||||
>
|
||||
{{ format.label }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
|
||||
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
|
||||
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
|
||||
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
|
||||
import {
|
||||
chipClass,
|
||||
iconBtnClass,
|
||||
panelClass,
|
||||
rowClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
|
||||
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
|
||||
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
|
||||
import ViewerControls from '@/components/load3d/controls/ViewerControls.vue'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import { getExportFormatOptions } from '@/extensions/core/load3d/constants'
|
||||
import type { LGraphNode } from '@/lib/litegraph/src/LGraphNode'
|
||||
import type {
|
||||
CameraConfig,
|
||||
GizmoMode,
|
||||
LightConfig,
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
SceneConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const {
|
||||
canUseLighting = true,
|
||||
canUseHdri = true,
|
||||
canUseGizmo = true,
|
||||
canExport = true,
|
||||
canUseBackgroundImage = true,
|
||||
canFitToViewer = true,
|
||||
canCenterCameraOnModel = true,
|
||||
canUseRecording = true,
|
||||
enableViewer = false,
|
||||
node = null,
|
||||
materialModes = ['original', 'clay', 'normal', 'wireframe'],
|
||||
hasSkeleton = false,
|
||||
sourceFormat = null
|
||||
} = defineProps<{
|
||||
canUseLighting?: boolean
|
||||
canUseHdri?: boolean
|
||||
canUseGizmo?: boolean
|
||||
canExport?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
canFitToViewer?: boolean
|
||||
canCenterCameraOnModel?: boolean
|
||||
canUseRecording?: boolean
|
||||
enableViewer?: boolean
|
||||
node?: LGraphNode | null
|
||||
materialModes?: readonly MaterialMode[]
|
||||
hasSkeleton?: boolean
|
||||
sourceFormat?: string | null
|
||||
}>()
|
||||
|
||||
const sceneConfig = defineModel<SceneConfig>('sceneConfig')
|
||||
const modelConfig = defineModel<ModelConfig>('modelConfig')
|
||||
const cameraConfig = defineModel<CameraConfig>('cameraConfig')
|
||||
const lightConfig = defineModel<LightConfig>('lightConfig')
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
(e: 'exportModel', format: string): void
|
||||
(e: 'fitToViewer'): void
|
||||
(e: 'centerCamera'): void
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
(e: 'startRecording'): void
|
||||
(e: 'stopRecording'): void
|
||||
(e: 'exportRecording'): void
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const categoryDefs = computed(() =>
|
||||
[
|
||||
{ key: 'scene', label: t('load3d.scene'), show: !!sceneConfig.value },
|
||||
{
|
||||
key: 'model',
|
||||
label: t('load3d.model3d'),
|
||||
show: !!modelConfig.value
|
||||
},
|
||||
{ key: 'camera', label: t('load3d.camera'), show: !!cameraConfig.value },
|
||||
{
|
||||
key: 'light',
|
||||
label: t('load3d.light'),
|
||||
show: canUseLighting && !!lightConfig.value && !!modelConfig.value
|
||||
},
|
||||
{
|
||||
key: 'hdri',
|
||||
label: t('load3d.hdri.label'),
|
||||
show: canUseHdri && !!lightConfig.value
|
||||
},
|
||||
{
|
||||
key: 'gizmo',
|
||||
label: t('load3d.gizmo.label'),
|
||||
show: canUseGizmo && !!modelConfig.value
|
||||
}
|
||||
].filter((c) => c.show)
|
||||
)
|
||||
|
||||
const activeCategory = ref('scene')
|
||||
const activeLabel = computed(
|
||||
() =>
|
||||
categoryDefs.value.find((c) => c.key === activeCategory.value)?.label ?? ''
|
||||
)
|
||||
watch(categoryDefs, (defs) => {
|
||||
if (!defs.some((c) => c.key === activeCategory.value)) {
|
||||
activeCategory.value = defs[0]?.key ?? 'scene'
|
||||
}
|
||||
})
|
||||
|
||||
const catMenuOpen = ref(false)
|
||||
const exportOpen = ref(false)
|
||||
|
||||
const sceneHasImage = computed(
|
||||
() =>
|
||||
!!sceneConfig.value?.backgroundImage &&
|
||||
sceneConfig.value.backgroundImage !== ''
|
||||
)
|
||||
const hdriActive = computed(
|
||||
() =>
|
||||
!!lightConfig.value?.hdri?.hdriPath && !!lightConfig.value?.hdri?.enabled
|
||||
)
|
||||
const isOriginalMaterial = computed(
|
||||
() => modelConfig.value?.materialMode === 'original'
|
||||
)
|
||||
const cameraFov = computed({
|
||||
get: () => cameraConfig.value?.fov ?? 0,
|
||||
set: (value) => {
|
||||
if (cameraConfig.value) cameraConfig.value.fov = value
|
||||
}
|
||||
})
|
||||
|
||||
const exportFormats = computed(() => getExportFormatOptions(sourceFormat))
|
||||
|
||||
const topBarRef = ref<HTMLElement | null>(null)
|
||||
const { width: topW } = useElementSize(topBarRef)
|
||||
const compactWidthThreshold = 480
|
||||
const compact = computed(
|
||||
() => topW.value > 0 && topW.value < compactWidthThreshold
|
||||
)
|
||||
|
||||
function selectCategory(key: string) {
|
||||
activeCategory.value = key
|
||||
catMenuOpen.value = false
|
||||
}
|
||||
|
||||
function onExport(format: string) {
|
||||
emit('exportModel', format)
|
||||
exportOpen.value = false
|
||||
}
|
||||
</script>
|
||||
205
src/components/load3d/controls/RecordingControls.test.ts
Normal file
205
src/components/load3d/controls/RecordingControls.test.ts
Normal file
@@ -0,0 +1,205 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { ref } from 'vue'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RecordingControls from '@/components/load3d/controls/RecordingControls.vue'
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: {
|
||||
en: {
|
||||
load3d: {
|
||||
startRecording: 'Start recording',
|
||||
stopRecording: 'Stop recording',
|
||||
exportRecording: 'Export recording',
|
||||
clearRecording: 'Clear recording'
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
type RenderOpts = {
|
||||
hasRecording?: boolean
|
||||
isRecording?: boolean
|
||||
recordingDuration?: number
|
||||
onStartRecording?: () => void
|
||||
onStopRecording?: () => void
|
||||
onExportRecording?: () => void
|
||||
onClearRecording?: () => void
|
||||
}
|
||||
|
||||
function renderComponent(opts: RenderOpts = {}) {
|
||||
const hasRecording = ref<boolean>(opts.hasRecording ?? false)
|
||||
const isRecording = ref<boolean>(opts.isRecording ?? false)
|
||||
const recordingDuration = ref<number>(opts.recordingDuration ?? 0)
|
||||
|
||||
const utils = render(RecordingControls, {
|
||||
props: {
|
||||
hasRecording: hasRecording.value,
|
||||
'onUpdate:hasRecording': (v: boolean | undefined) => {
|
||||
if (v !== undefined) hasRecording.value = v
|
||||
},
|
||||
isRecording: isRecording.value,
|
||||
'onUpdate:isRecording': (v: boolean | undefined) => {
|
||||
if (v !== undefined) isRecording.value = v
|
||||
},
|
||||
recordingDuration: recordingDuration.value,
|
||||
'onUpdate:recordingDuration': (v: number | undefined) => {
|
||||
if (v !== undefined) recordingDuration.value = v
|
||||
},
|
||||
onStartRecording: opts.onStartRecording,
|
||||
onStopRecording: opts.onStopRecording,
|
||||
onExportRecording: opts.onExportRecording,
|
||||
onClearRecording: opts.onClearRecording
|
||||
},
|
||||
global: {
|
||||
plugins: [i18n],
|
||||
directives: { tooltip: () => {} }
|
||||
}
|
||||
})
|
||||
|
||||
return { ...utils, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('RecordingControls', () => {
|
||||
it('shows the start-recording button initially', () => {
|
||||
renderComponent()
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Start recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Stop recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the stop-recording button while recording is in progress', () => {
|
||||
renderComponent({ isRecording: true })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Stop recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Start recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits startRecording when the button is clicked from a stopped state', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
isRecording: false,
|
||||
onStartRecording,
|
||||
onStopRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Start recording' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
expect(onStopRecording).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('emits stopRecording when the button is clicked from a recording state', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
isRecording: true,
|
||||
onStartRecording,
|
||||
onStopRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
|
||||
|
||||
expect(onStopRecording).toHaveBeenCalledOnce()
|
||||
expect(onStartRecording).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('hides the export and clear buttons when there is no recording', () => {
|
||||
renderComponent({ hasRecording: false, isRecording: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export recording' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows the export and clear buttons once a recording exists', () => {
|
||||
renderComponent({ hasRecording: true, isRecording: false })
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Export recording' })
|
||||
).toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Clear recording' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the export and clear buttons during a new recording even if a previous one exists', () => {
|
||||
renderComponent({ hasRecording: true, isRecording: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Export recording' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Clear recording' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('emits exportRecording and clearRecording from their respective buttons', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
onExportRecording,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Export recording' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Clear recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('renders the formatted duration as MM:SS once a recording exists', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
recordingDuration: 75
|
||||
})
|
||||
|
||||
expect(screen.getByTestId('load3d-recording-duration')).toHaveTextContent(
|
||||
'01:15'
|
||||
)
|
||||
})
|
||||
|
||||
it('hides the duration display while a recording is in progress', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: true,
|
||||
recordingDuration: 30
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-recording-duration')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the duration display when recordingDuration is zero', () => {
|
||||
renderComponent({
|
||||
hasRecording: true,
|
||||
isRecording: false,
|
||||
recordingDuration: 0
|
||||
})
|
||||
|
||||
expect(
|
||||
screen.queryByTestId('load3d-recording-duration')
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
126
src/components/load3d/controls/RecordingControls.vue
Normal file
126
src/components/load3d/controls/RecordingControls.vue
Normal file
@@ -0,0 +1,126 @@
|
||||
<template>
|
||||
<div class="relative rounded-lg bg-backdrop/30">
|
||||
<div class="flex flex-col gap-2">
|
||||
<Button
|
||||
v-tooltip.right="{
|
||||
value: isRecording
|
||||
? $t('load3d.stopRecording')
|
||||
: $t('load3d.startRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
:class="
|
||||
cn(
|
||||
'rounded-full',
|
||||
isRecording && 'recording-button-blink text-red-500'
|
||||
)
|
||||
"
|
||||
:aria-label="
|
||||
isRecording ? $t('load3d.stopRecording') : $t('load3d.startRecording')
|
||||
"
|
||||
@click="toggleRecording"
|
||||
>
|
||||
<i
|
||||
:class="[
|
||||
'pi',
|
||||
isRecording ? 'pi-circle-fill' : 'pi-video',
|
||||
'text-lg text-base-foreground'
|
||||
]"
|
||||
/>
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.exportRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.exportRecording')"
|
||||
@click="handleExportRecording"
|
||||
>
|
||||
<i class="pi pi-download text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<Button
|
||||
v-if="hasRecording && !isRecording"
|
||||
v-tooltip.right="{
|
||||
value: $t('load3d.clearRecording'),
|
||||
showDelay: 300
|
||||
}"
|
||||
size="icon"
|
||||
variant="textonly"
|
||||
class="rounded-full"
|
||||
:aria-label="$t('load3d.clearRecording')"
|
||||
@click="handleClearRecording"
|
||||
>
|
||||
<i class="pi pi-trash text-lg text-base-foreground" />
|
||||
</Button>
|
||||
|
||||
<div
|
||||
v-if="recordingDuration && recordingDuration > 0 && !isRecording"
|
||||
class="mt-1 text-center text-xs text-base-foreground"
|
||||
data-testid="load3d-recording-duration"
|
||||
>
|
||||
{{ formatDuration(recordingDuration) }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startRecording'): void
|
||||
(e: 'stopRecording'): void
|
||||
(e: 'exportRecording'): void
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
function toggleRecording() {
|
||||
if (isRecording.value) {
|
||||
emit('stopRecording')
|
||||
} else {
|
||||
emit('startRecording')
|
||||
}
|
||||
}
|
||||
|
||||
function handleExportRecording() {
|
||||
emit('exportRecording')
|
||||
}
|
||||
|
||||
function handleClearRecording() {
|
||||
emit('clearRecording')
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.recording-button-blink {
|
||||
animation: blink 1s infinite;
|
||||
}
|
||||
|
||||
@keyframes blink {
|
||||
0%,
|
||||
100% {
|
||||
opacity: 1;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.5;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -1,47 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import CameraMenuGroup from '@/components/load3d/menubar/CameraMenuGroup.vue'
|
||||
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<CameraConfig> = {}): CameraConfig {
|
||||
return { cameraType: 'perspective', fov: 75, ...overrides }
|
||||
}
|
||||
|
||||
function renderGroup(config = makeConfig()) {
|
||||
const result = render(CameraMenuGroup, {
|
||||
props: { config },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup(), config }
|
||||
}
|
||||
|
||||
describe('CameraMenuGroup', () => {
|
||||
it('switches the projection type', async () => {
|
||||
const { user, config } = renderGroup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Perspective' }))
|
||||
|
||||
expect(config.cameraType).toBe('orthographic')
|
||||
})
|
||||
|
||||
it('offers the FOV control only for a perspective camera', () => {
|
||||
renderGroup(makeConfig({ cameraType: 'orthographic' }))
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'FOV' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Orthographic' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,86 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.switchProjection'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.switchProjection') : undefined"
|
||||
@click="switchCamera"
|
||||
>
|
||||
<i class="icon-[lucide--camera] size-4" />
|
||||
<span v-if="!compact">{{ cameraTypeLabel }}</span>
|
||||
</button>
|
||||
|
||||
<Popover v-if="isPerspective">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--focus] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="cn(panelClass, 'w-56')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<span class="text-sm text-base-foreground">{{ t('load3d.fov') }}</span>
|
||||
<Slider
|
||||
:model-value="[fov]"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:model-value="setFov"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { CameraConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const { compact = false } = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<CameraConfig>('config')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const cameraType = computed(() => config.value?.cameraType)
|
||||
const isPerspective = computed(() => cameraType.value === 'perspective')
|
||||
const cameraTypeLabel = computed(() =>
|
||||
cameraType.value ? t(`load3d.cameraType.${cameraType.value}`) : ''
|
||||
)
|
||||
const fov = computed(() => config.value?.fov ?? 0)
|
||||
|
||||
function switchCamera() {
|
||||
if (!config.value) return
|
||||
config.value.cameraType =
|
||||
config.value.cameraType === 'perspective' ? 'orthographic' : 'perspective'
|
||||
}
|
||||
|
||||
function setFov(value?: number[]) {
|
||||
if (config.value && value?.length) config.value.fov = value[0]
|
||||
}
|
||||
</script>
|
||||
@@ -1,72 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import GizmoMenuGroup from '@/components/load3d/menubar/GizmoMenuGroup.vue'
|
||||
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(enabled: boolean): ModelConfig {
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
gizmo: {
|
||||
enabled,
|
||||
mode: 'translate',
|
||||
position: { x: 0, y: 0, z: 0 },
|
||||
rotation: { x: 0, y: 0, z: 0 },
|
||||
scale: { x: 1, y: 1, z: 1 }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config: ModelConfig
|
||||
onToggleGizmo?: (enabled: boolean) => void
|
||||
onSetGizmoMode?: (mode: string) => void
|
||||
}
|
||||
|
||||
function renderGroup(props: Props) {
|
||||
const result = render(GizmoMenuGroup, {
|
||||
props,
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('GizmoMenuGroup', () => {
|
||||
it('enables the gizmo and reveals the mode controls', async () => {
|
||||
const config = makeConfig(false)
|
||||
const onToggleGizmo = vi.fn()
|
||||
const { user } = renderGroup({ config, onToggleGizmo })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Rotate' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Gizmo' }))
|
||||
|
||||
expect(onToggleGizmo).toHaveBeenCalledWith(true)
|
||||
expect(config.gizmo?.enabled).toBe(true)
|
||||
expect(screen.getByRole('button', { name: 'Rotate' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('sets the transform mode', async () => {
|
||||
const config = makeConfig(true)
|
||||
const onSetGizmoMode = vi.fn()
|
||||
const { user } = renderGroup({ config, onSetGizmoMode })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Rotate' }))
|
||||
|
||||
expect(onSetGizmoMode).toHaveBeenCalledWith('rotate')
|
||||
expect(config.gizmo?.mode).toBe('rotate')
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.toggle'))"
|
||||
:class="actionClass(gizmoEnabled)"
|
||||
:aria-pressed="gizmoEnabled"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.toggle') : undefined"
|
||||
@click="toggleGizmo"
|
||||
>
|
||||
<i class="icon-[lucide--axis-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.toggle') }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="gizmoEnabled">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.translate'))"
|
||||
:class="actionClass(gizmoMode === 'translate')"
|
||||
:aria-pressed="gizmoMode === 'translate'"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.translate') : undefined"
|
||||
@click="setGizmoMode('translate')"
|
||||
>
|
||||
<i class="icon-[lucide--move] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.translate') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.rotate'))"
|
||||
:class="actionClass(gizmoMode === 'rotate')"
|
||||
:aria-pressed="gizmoMode === 'rotate'"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.rotate') : undefined"
|
||||
@click="setGizmoMode('rotate')"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.rotate') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.scale'))"
|
||||
:class="actionClass(gizmoMode === 'scale')"
|
||||
:aria-pressed="gizmoMode === 'scale'"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.scale') : undefined"
|
||||
@click="setGizmoMode('scale')"
|
||||
>
|
||||
<i class="icon-[lucide--scale-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.scale') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.gizmo.reset'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.gizmo.reset') : undefined"
|
||||
@click="resetGizmoTransform"
|
||||
>
|
||||
<i class="icon-[lucide--rotate-ccw] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.gizmo.reset') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
|
||||
import type {
|
||||
GizmoMode,
|
||||
ModelConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
|
||||
const { compact = false } = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<ModelConfig>('config')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggleGizmo', enabled: boolean): void
|
||||
(e: 'setGizmoMode', mode: GizmoMode): void
|
||||
(e: 'resetGizmoTransform'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const gizmoEnabled = computed(() => config.value?.gizmo?.enabled ?? false)
|
||||
const gizmoMode = computed(() => config.value?.gizmo?.mode ?? 'translate')
|
||||
|
||||
function toggleGizmo() {
|
||||
const gizmo = config.value?.gizmo
|
||||
if (!gizmo) return
|
||||
gizmo.enabled = !gizmo.enabled
|
||||
emit('toggleGizmo', gizmo.enabled)
|
||||
}
|
||||
|
||||
function setGizmoMode(mode: GizmoMode) {
|
||||
const gizmo = config.value?.gizmo
|
||||
if (!gizmo) return
|
||||
gizmo.mode = mode
|
||||
emit('setGizmoMode', mode)
|
||||
}
|
||||
|
||||
function resetGizmoTransform() {
|
||||
emit('resetGizmoTransform')
|
||||
}
|
||||
</script>
|
||||
@@ -1,74 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import HdriMenuGroup from '@/components/load3d/menubar/HdriMenuGroup.vue'
|
||||
import type {
|
||||
HDRIConfig,
|
||||
LightConfig
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(hdri?: Partial<HDRIConfig>): LightConfig {
|
||||
return {
|
||||
intensity: 5,
|
||||
hdri: hdri
|
||||
? {
|
||||
enabled: false,
|
||||
hdriPath: '',
|
||||
showAsBackground: false,
|
||||
intensity: 1,
|
||||
...hdri
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config?: LightConfig
|
||||
sceneHasImage?: boolean
|
||||
onUpdateHdriFile?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderGroup(props: Props = {}) {
|
||||
const result = render(HdriMenuGroup, {
|
||||
props: { config: makeConfig({}), ...props },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('HdriMenuGroup', () => {
|
||||
it('shows the upload button when no HDRI is loaded', () => {
|
||||
renderGroup()
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Upload' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the upload when a background image is set and no HDRI exists', () => {
|
||||
renderGroup({ config: makeConfig({ hdriPath: '' }), sceneHasImage: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Upload' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('toggles enabled and forwards removal once a file is loaded', async () => {
|
||||
const onUpdateHdriFile = vi.fn()
|
||||
const config = makeConfig({ hdriPath: 'env.hdr', enabled: false })
|
||||
const { user } = renderGroup({ config, onUpdateHdriFile })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'HDRI' }))
|
||||
expect(config.hdri?.enabled).toBe(true)
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Remove' }))
|
||||
expect(onUpdateHdriFile).toHaveBeenCalledWith(null)
|
||||
})
|
||||
})
|
||||
@@ -1,130 +0,0 @@
|
||||
<template>
|
||||
<template v-if="!sceneHasImage || hdriPath">
|
||||
<button
|
||||
v-tooltip.bottom="
|
||||
tip(
|
||||
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
|
||||
)
|
||||
"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="
|
||||
compact
|
||||
? hdriPath
|
||||
? t('load3d.hdri.changeFile')
|
||||
: t('load3d.hdri.uploadFile')
|
||||
: undefined
|
||||
"
|
||||
@click="hdriFileRef?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--upload] size-4" />
|
||||
<span v-if="!compact">{{
|
||||
hdriPath ? t('load3d.hdri.changeFile') : t('load3d.hdri.uploadFile')
|
||||
}}</span>
|
||||
</button>
|
||||
<input
|
||||
ref="hdriFileRef"
|
||||
type="file"
|
||||
:accept="SUPPORTED_HDRI_EXTENSIONS_ACCEPT"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@change="onHdriFilePicked"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<template v-if="hdriPath">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.hdri.label'))"
|
||||
:class="actionClass(hdriEnabled)"
|
||||
:aria-pressed="hdriEnabled"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.hdri.label') : undefined"
|
||||
@click="toggleHdriEnabled"
|
||||
>
|
||||
<i class="icon-[lucide--globe] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.hdri.label') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.hdri.showAsBackground'))"
|
||||
:class="actionClass(hdriShowAsBackground)"
|
||||
:aria-pressed="hdriShowAsBackground"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.hdri.showAsBackground') : undefined"
|
||||
@click="toggleHdriShowAsBackground"
|
||||
>
|
||||
<i class="icon-[lucide--image] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.hdri.showAsBackground') }}</span>
|
||||
</button>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.hdri.removeFile'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.hdri.removeFile') : undefined"
|
||||
@click="removeHdri"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.hdri.removeFile') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import { actionClass, tip } from '@/components/load3d/menubar/menuBarStyles'
|
||||
import {
|
||||
SUPPORTED_HDRI_EXTENSIONS,
|
||||
SUPPORTED_HDRI_EXTENSIONS_ACCEPT
|
||||
} from '@/extensions/core/load3d/constants'
|
||||
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { useToastStore } from '@/platform/updates/common/toastStore'
|
||||
|
||||
const { compact = false, sceneHasImage = false } = defineProps<{
|
||||
compact?: boolean
|
||||
sceneHasImage?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<LightConfig>('config')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateHdriFile', file: File | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const hdriPath = computed(() => config.value?.hdri?.hdriPath ?? '')
|
||||
const hdriEnabled = computed(() => config.value?.hdri?.enabled ?? false)
|
||||
const hdriShowAsBackground = computed(
|
||||
() => config.value?.hdri?.showAsBackground ?? false
|
||||
)
|
||||
|
||||
const hdriFileRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function onHdriFilePicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0] ?? null
|
||||
input.value = ''
|
||||
if (file) {
|
||||
const ext = `.${file.name.split('.').pop()?.toLowerCase() ?? ''}`
|
||||
if (!SUPPORTED_HDRI_EXTENSIONS.has(ext)) {
|
||||
useToastStore().addAlert(t('toastMessages.unsupportedHDRIFormat'))
|
||||
return
|
||||
}
|
||||
}
|
||||
emit('updateHdriFile', file)
|
||||
}
|
||||
|
||||
function toggleHdriEnabled() {
|
||||
const hdri = config.value?.hdri
|
||||
if (hdri) hdri.enabled = !hdri.enabled
|
||||
}
|
||||
|
||||
function toggleHdriShowAsBackground() {
|
||||
const hdri = config.value?.hdri
|
||||
if (hdri) hdri.showAsBackground = !hdri.showAsBackground
|
||||
}
|
||||
|
||||
function removeHdri() {
|
||||
emit('updateHdriFile', null)
|
||||
}
|
||||
</script>
|
||||
@@ -1,74 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import LightMenuGroup from '@/components/load3d/menubar/LightMenuGroup.vue'
|
||||
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const settingValues: Record<string, number> = {
|
||||
'Comfy.Load3D.LightIntensityMinimum': 1,
|
||||
'Comfy.Load3D.LightIntensityMaximum': 10,
|
||||
'Comfy.Load3D.LightAdjustmentIncrement': 0.1
|
||||
}
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: (key: string) => settingValues[key] })
|
||||
}))
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function renderGroup(isOriginalMaterial: boolean) {
|
||||
const config: LightConfig = { intensity: 5 }
|
||||
return render(LightMenuGroup, {
|
||||
props: { config, isOriginalMaterial },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
}
|
||||
|
||||
describe('LightMenuGroup', () => {
|
||||
it('shows the intensity control for the original material', () => {
|
||||
renderGroup(true)
|
||||
|
||||
expect(
|
||||
screen.getByRole('button', { name: 'Intensity' })
|
||||
).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('explains intensity is unavailable for other materials', () => {
|
||||
renderGroup(false)
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Intensity' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(screen.getByText('Original material only')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('drives HDRI intensity (0-5) when an HDRI environment is active', async () => {
|
||||
const config: LightConfig = {
|
||||
intensity: 5,
|
||||
hdri: {
|
||||
enabled: true,
|
||||
hdriPath: 'env.hdr',
|
||||
showAsBackground: false,
|
||||
intensity: 2
|
||||
}
|
||||
}
|
||||
render(LightMenuGroup, {
|
||||
props: { config, isOriginalMaterial: true },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
const user = userEvent.setup()
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Intensity' }))
|
||||
|
||||
const slider = screen.getByRole('slider')
|
||||
expect(slider).toHaveAttribute('aria-valuemax', '5')
|
||||
expect(slider).toHaveAttribute('aria-valuenow', '2')
|
||||
})
|
||||
})
|
||||
@@ -1,105 +0,0 @@
|
||||
<template>
|
||||
<Popover v-if="isOriginalMaterial">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.intensity'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.intensity') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--sun] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.intensity') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="cn(panelClass, 'w-56')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<span class="text-sm text-base-foreground">{{
|
||||
t('load3d.lightIntensity')
|
||||
}}</span>
|
||||
<Slider
|
||||
:model-value="[sliderValue]"
|
||||
:min="sliderMin"
|
||||
:max="sliderMax"
|
||||
:step="sliderStep"
|
||||
class="w-full"
|
||||
@update:model-value="onIntensityUpdate"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<span v-else class="px-2 text-sm text-muted">{{
|
||||
t('load3d.menuBar.originalMaterialOnly')
|
||||
}}</span>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { LightConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const { compact = false, isOriginalMaterial = false } = defineProps<{
|
||||
compact?: boolean
|
||||
isOriginalMaterial?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<LightConfig>('config')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const settingStore = useSettingStore()
|
||||
const lightIntensityMinimum = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMinimum'
|
||||
)
|
||||
const lightIntensityMaximum = settingStore.get(
|
||||
'Comfy.Load3D.LightIntensityMaximum'
|
||||
)
|
||||
const lightAdjustmentIncrement = settingStore.get(
|
||||
'Comfy.Load3D.LightAdjustmentIncrement'
|
||||
)
|
||||
|
||||
const usesHdriIntensity = computed(
|
||||
() => !!config.value?.hdri?.hdriPath?.length && !!config.value?.hdri?.enabled
|
||||
)
|
||||
|
||||
const sliderMin = computed(() =>
|
||||
usesHdriIntensity.value ? 0 : lightIntensityMinimum
|
||||
)
|
||||
const sliderMax = computed(() =>
|
||||
usesHdriIntensity.value ? 5 : lightIntensityMaximum
|
||||
)
|
||||
const sliderStep = computed(() =>
|
||||
usesHdriIntensity.value ? 0.1 : lightAdjustmentIncrement
|
||||
)
|
||||
const sliderValue = computed(() =>
|
||||
usesHdriIntensity.value
|
||||
? (config.value?.hdri?.intensity ?? 1)
|
||||
: (config.value?.intensity ?? lightIntensityMinimum)
|
||||
)
|
||||
|
||||
function onIntensityUpdate(value?: number[]) {
|
||||
if (!value?.length || !config.value) return
|
||||
const next = value[0]
|
||||
if (usesHdriIntensity.value) {
|
||||
if (config.value.hdri) config.value.hdri.intensity = next
|
||||
} else {
|
||||
config.value.intensity = next
|
||||
}
|
||||
}
|
||||
</script>
|
||||
@@ -1,69 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import ModelMenuGroup from '@/components/load3d/menubar/ModelMenuGroup.vue'
|
||||
import type { ModelConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<ModelConfig> = {}): ModelConfig {
|
||||
return {
|
||||
upDirection: 'original',
|
||||
materialMode: 'original',
|
||||
showSkeleton: false,
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
function renderGroup(
|
||||
props: { config?: ModelConfig; hasSkeleton?: boolean } = {}
|
||||
) {
|
||||
const result = render(ModelMenuGroup, {
|
||||
props: { config: makeConfig(), ...props },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('ModelMenuGroup', () => {
|
||||
it('sets the up direction from the popover', async () => {
|
||||
const config = makeConfig()
|
||||
const { user } = renderGroup({ config })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Up Direction' }))
|
||||
await user.click(screen.getByRole('button', { name: '+Y' }))
|
||||
|
||||
expect(config.upDirection).toBe('+y')
|
||||
})
|
||||
|
||||
it('sets the material mode from the popover', async () => {
|
||||
const config = makeConfig()
|
||||
const { user } = renderGroup({ config })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Material' }))
|
||||
await user.click(screen.getByRole('button', { name: 'Wireframe' }))
|
||||
|
||||
expect(config.materialMode).toBe('wireframe')
|
||||
})
|
||||
|
||||
it('toggles the skeleton only when supported', async () => {
|
||||
const config = makeConfig({ showSkeleton: false })
|
||||
const { user, rerender } = renderGroup({ config, hasSkeleton: false })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'Skeleton' })
|
||||
).not.toBeInTheDocument()
|
||||
|
||||
await rerender({ config, hasSkeleton: true })
|
||||
await user.click(screen.getByRole('button', { name: 'Skeleton' }))
|
||||
|
||||
expect(config.showSkeleton).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -1,135 +0,0 @@
|
||||
<template>
|
||||
<Popover>
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.upDirection'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.upDirection') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--move-3d] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.upDirection') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="d in upDirections"
|
||||
:key="d"
|
||||
type="button"
|
||||
:class="cn(rowClass, upDirection === d && 'bg-button-active-surface')"
|
||||
@click="setUpDirection(d)"
|
||||
>
|
||||
{{ d.toUpperCase() }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<Popover v-if="materialModes.length">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.material'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.material') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--box] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.material') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
v-for="m in materialModes"
|
||||
:key="m"
|
||||
type="button"
|
||||
:class="cn(rowClass, materialMode === m && 'bg-button-active-surface')"
|
||||
@click="setMaterialMode(m)"
|
||||
>
|
||||
{{ t(`load3d.materialModes.${m}`) }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
<button
|
||||
v-if="hasSkeleton"
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.skeleton'))"
|
||||
:class="actionClass(showSkeleton)"
|
||||
:aria-pressed="showSkeleton"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.skeleton') : undefined"
|
||||
@click="toggleSkeleton"
|
||||
>
|
||||
<i class="icon-[lucide--bone] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.skeleton') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
rowClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import type {
|
||||
MaterialMode,
|
||||
ModelConfig,
|
||||
UpDirection
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const {
|
||||
compact = false,
|
||||
hasSkeleton = false,
|
||||
materialModes = ['original', 'clay', 'normal', 'wireframe']
|
||||
} = defineProps<{
|
||||
compact?: boolean
|
||||
hasSkeleton?: boolean
|
||||
materialModes?: readonly MaterialMode[]
|
||||
}>()
|
||||
|
||||
const config = defineModel<ModelConfig>('config')
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const upDirection = computed(() => config.value?.upDirection)
|
||||
const materialMode = computed(() => config.value?.materialMode)
|
||||
const showSkeleton = computed(() => config.value?.showSkeleton ?? false)
|
||||
|
||||
const upDirections: UpDirection[] = [
|
||||
'original',
|
||||
'-x',
|
||||
'+x',
|
||||
'-y',
|
||||
'+y',
|
||||
'-z',
|
||||
'+z'
|
||||
]
|
||||
|
||||
function setUpDirection(direction: UpDirection) {
|
||||
if (config.value) config.value.upDirection = direction
|
||||
}
|
||||
|
||||
function setMaterialMode(mode: MaterialMode) {
|
||||
if (config.value) config.value.materialMode = mode
|
||||
}
|
||||
|
||||
function toggleSkeleton() {
|
||||
if (config.value) config.value.showSkeleton = !config.value.showSkeleton
|
||||
}
|
||||
</script>
|
||||
@@ -1,138 +0,0 @@
|
||||
import { render, screen, within } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import RecordMenuControl from '@/components/load3d/menubar/RecordMenuControl.vue'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
type Props = {
|
||||
isRecording?: boolean
|
||||
hasRecording?: boolean
|
||||
recordingDuration?: number
|
||||
onStartRecording?: () => void
|
||||
onStopRecording?: () => void
|
||||
onExportRecording?: () => void
|
||||
onClearRecording?: () => void
|
||||
}
|
||||
|
||||
function renderControl(props: Props = {}) {
|
||||
const result = render(RecordMenuControl, {
|
||||
props,
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
async function openRecordingMenu(user: ReturnType<typeof userEvent.setup>) {
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Video recording of the scene [mp4]' })
|
||||
)
|
||||
}
|
||||
|
||||
describe('RecordMenuControl', () => {
|
||||
it('starts recording when idle', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const { user } = renderControl({ isRecording: false, onStartRecording })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Record' }))
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('stops recording when active', async () => {
|
||||
const onStopRecording = vi.fn()
|
||||
const { user } = renderControl({ isRecording: true, onStopRecording })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Stop recording' }))
|
||||
|
||||
expect(onStopRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows the recording duration once a recording exists', () => {
|
||||
renderControl({
|
||||
isRecording: false,
|
||||
hasRecording: true,
|
||||
recordingDuration: 65
|
||||
})
|
||||
|
||||
expect(screen.getByText('01:05')).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('downloads the recording from the menu', async () => {
|
||||
const onExportRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onExportRecording
|
||||
})
|
||||
|
||||
await openRecordingMenu(user)
|
||||
await user.click(screen.getByRole('button', { name: 'Download Recording' }))
|
||||
|
||||
expect(onExportRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('starts a new recording from the menu', async () => {
|
||||
const onStartRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onStartRecording
|
||||
})
|
||||
|
||||
await openRecordingMenu(user)
|
||||
await user.click(
|
||||
screen.getByRole('button', { name: 'Start New Recording' })
|
||||
)
|
||||
|
||||
expect(onStartRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deletes the recording from the menu', async () => {
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await openRecordingMenu(user)
|
||||
await user.click(
|
||||
within(screen.getByRole('dialog')).getByRole('button', {
|
||||
name: 'Delete Recording'
|
||||
})
|
||||
)
|
||||
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('deletes the recording via the chip dismiss button', async () => {
|
||||
const onClearRecording = vi.fn()
|
||||
const { user } = renderControl({
|
||||
hasRecording: true,
|
||||
recordingDuration: 4,
|
||||
onClearRecording
|
||||
})
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Delete Recording' }))
|
||||
|
||||
expect(onClearRecording).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('shows only the stop control while recording is in progress', () => {
|
||||
renderControl({ isRecording: true, hasRecording: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', {
|
||||
name: 'Video recording of the scene [mp4]'
|
||||
})
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
})
|
||||
@@ -1,141 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
v-if="isRecording"
|
||||
v-tooltip.top="tip(t('load3d.menuBar.stopRecording'))"
|
||||
:class="chipClass"
|
||||
type="button"
|
||||
:aria-label="t('load3d.menuBar.stopRecording')"
|
||||
@click="emit('stopRecording')"
|
||||
>
|
||||
<span class="size-2 animate-pulse rounded-full bg-red-500" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.recording') }}</span>
|
||||
</button>
|
||||
|
||||
<div
|
||||
v-else-if="hasRecording"
|
||||
class="flex shrink-0 items-center gap-0.5 rounded-lg bg-button-active-surface py-0.5 pr-0.5 pl-1 text-sm text-base-foreground"
|
||||
>
|
||||
<Popover v-model:open="menuOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.top="tip(t('load3d.menuBar.videoRecordingTooltip'))"
|
||||
class="focus-visible:ring-ring flex items-center gap-1.5 rounded-md border-0 bg-transparent px-1 py-0.5 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
|
||||
type="button"
|
||||
:aria-label="t('load3d.menuBar.videoRecordingTooltip')"
|
||||
data-testid="load3d-recording-duration"
|
||||
>
|
||||
<i class="icon-[lucide--film] size-4" />
|
||||
{{ formatDuration(recordingDuration ?? 0) }}
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="panelClass"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(rowClass, 'gap-2')"
|
||||
@click="downloadRecording"
|
||||
>
|
||||
<i class="icon-[lucide--download] size-4" />
|
||||
{{ t('load3d.menuBar.downloadRecording') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(rowClass, 'gap-2')"
|
||||
@click="startNewRecording"
|
||||
>
|
||||
<i class="icon-[lucide--video] size-4" />
|
||||
{{ t('load3d.menuBar.startNewRecording') }}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
:class="cn(rowClass, 'gap-2')"
|
||||
@click="deleteRecording"
|
||||
>
|
||||
<i class="icon-[lucide--trash-2] size-4" />
|
||||
{{ t('load3d.menuBar.deleteRecording') }}
|
||||
</button>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
v-tooltip.top="tip(t('load3d.menuBar.deleteRecording'))"
|
||||
class="focus-visible:ring-ring flex size-6 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1"
|
||||
type="button"
|
||||
:aria-label="t('load3d.menuBar.deleteRecording')"
|
||||
@click="emit('clearRecording')"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<button
|
||||
v-else
|
||||
v-tooltip.top="tip(t('load3d.menuBar.record'))"
|
||||
:class="chipClass"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.record') : undefined"
|
||||
@click="emit('startRecording')"
|
||||
>
|
||||
<i class="icon-[lucide--video] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.record') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
import { ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
chipClass,
|
||||
panelClass,
|
||||
rowClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { compact = false } = defineProps<{
|
||||
compact?: boolean
|
||||
}>()
|
||||
|
||||
const isRecording = defineModel<boolean>('isRecording')
|
||||
const hasRecording = defineModel<boolean>('hasRecording')
|
||||
const recordingDuration = defineModel<number>('recordingDuration')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'startRecording'): void
|
||||
(e: 'stopRecording'): void
|
||||
(e: 'exportRecording'): void
|
||||
(e: 'clearRecording'): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const menuOpen = ref(false)
|
||||
|
||||
function downloadRecording() {
|
||||
menuOpen.value = false
|
||||
emit('exportRecording')
|
||||
}
|
||||
|
||||
function startNewRecording() {
|
||||
menuOpen.value = false
|
||||
emit('startRecording')
|
||||
}
|
||||
|
||||
function deleteRecording() {
|
||||
menuOpen.value = false
|
||||
emit('clearRecording')
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number) {
|
||||
const minutes = Math.floor(seconds / 60)
|
||||
const remainingSeconds = Math.floor(seconds % 60)
|
||||
return `${minutes.toString().padStart(2, '0')}:${remainingSeconds.toString().padStart(2, '0')}`
|
||||
}
|
||||
</script>
|
||||
@@ -1,108 +0,0 @@
|
||||
import { render, screen } from '@testing-library/vue'
|
||||
import userEvent from '@testing-library/user-event'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createI18n } from 'vue-i18n'
|
||||
|
||||
import SceneMenuGroup from '@/components/load3d/menubar/SceneMenuGroup.vue'
|
||||
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import enMessages from '@/locales/en/main.json' with { type: 'json' }
|
||||
|
||||
const i18n = createI18n({
|
||||
legacy: false,
|
||||
locale: 'en',
|
||||
messages: { en: enMessages }
|
||||
})
|
||||
|
||||
function makeConfig(overrides: Partial<SceneConfig> = {}): SceneConfig {
|
||||
return {
|
||||
showGrid: true,
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled',
|
||||
...overrides
|
||||
}
|
||||
}
|
||||
|
||||
type Props = {
|
||||
config?: SceneConfig
|
||||
fov?: number
|
||||
hdriActive?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
onUpdateBackgroundImage?: (file: File | null) => void
|
||||
}
|
||||
|
||||
function renderGroup(props: Props = {}) {
|
||||
const result = render(SceneMenuGroup, {
|
||||
props: { config: makeConfig(), ...props },
|
||||
global: { plugins: [i18n], directives: { tooltip: () => {} } }
|
||||
})
|
||||
return { ...result, user: userEvent.setup() }
|
||||
}
|
||||
|
||||
describe('SceneMenuGroup', () => {
|
||||
it('toggles showGrid on the bound config', async () => {
|
||||
const config = makeConfig({ showGrid: true })
|
||||
const { user } = renderGroup({ config })
|
||||
|
||||
await user.click(screen.getByRole('button', { name: 'Show grid' }))
|
||||
|
||||
expect(config.showGrid).toBe(false)
|
||||
})
|
||||
|
||||
it('hides background color and image controls while HDRI is active', () => {
|
||||
renderGroup({ hdriActive: true })
|
||||
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'BG Color' })
|
||||
).not.toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'BG Image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('hides the image upload when background images are not allowed', () => {
|
||||
renderGroup({ canUseBackgroundImage: false })
|
||||
|
||||
expect(screen.getByRole('button', { name: 'BG Color' })).toBeInTheDocument()
|
||||
expect(
|
||||
screen.queryByRole('button', { name: 'BG Image' })
|
||||
).not.toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('shows panorama and remove once a background image exists', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderGroup({
|
||||
config: makeConfig({ backgroundImage: 'bg.png' }),
|
||||
onUpdateBackgroundImage
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'Panorama' })).toBeInTheDocument()
|
||||
await user.click(screen.getByRole('button', { name: 'Remove BG' }))
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(null)
|
||||
})
|
||||
|
||||
it('exposes the FOV control while a panorama background is active', () => {
|
||||
renderGroup({
|
||||
config: makeConfig({
|
||||
backgroundImage: 'bg.png',
|
||||
backgroundRenderMode: 'panorama'
|
||||
}),
|
||||
fov: 75
|
||||
})
|
||||
|
||||
expect(screen.getByRole('button', { name: 'FOV' })).toBeInTheDocument()
|
||||
})
|
||||
|
||||
it('clears the file input so the same image can be re-picked', async () => {
|
||||
const onUpdateBackgroundImage = vi.fn()
|
||||
const { user } = renderGroup({ onUpdateBackgroundImage })
|
||||
const input = screen.getByTestId<HTMLInputElement>('scene-bg-image-input')
|
||||
const file = new File(['x'], 'bg.png', { type: 'image/png' })
|
||||
|
||||
await user.upload(input, file)
|
||||
|
||||
expect(onUpdateBackgroundImage).toHaveBeenCalledWith(file)
|
||||
expect(input.value).toBe('')
|
||||
})
|
||||
})
|
||||
@@ -1,190 +0,0 @@
|
||||
<template>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.showGrid'))"
|
||||
:class="actionClass(showGrid)"
|
||||
:aria-pressed="showGrid"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.showGrid') : undefined"
|
||||
@click="toggleGrid"
|
||||
>
|
||||
<i class="icon-[lucide--grid-3x3] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.showGrid') }}</span>
|
||||
</button>
|
||||
|
||||
<template v-if="!hasImage && !hdriActive">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.bgColor'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.bgColor') : undefined"
|
||||
@click="colorRef?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--palette] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.bgColor') }}</span>
|
||||
</button>
|
||||
<input
|
||||
ref="colorRef"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
:value="bgColor"
|
||||
@input="setBackgroundColor"
|
||||
/>
|
||||
<template v-if="canUseBackgroundImage">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.bgImage'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.bgImage') : undefined"
|
||||
@click="bgImageRef?.click()"
|
||||
>
|
||||
<i class="icon-[lucide--image] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.bgImage') }}</span>
|
||||
</button>
|
||||
<input
|
||||
ref="bgImageRef"
|
||||
type="file"
|
||||
accept="image/*"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
data-testid="scene-bg-image-input"
|
||||
@change="onBackgroundImagePicked"
|
||||
/>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<template v-if="hasImage">
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.panorama'))"
|
||||
:class="actionClass(isPanorama)"
|
||||
:aria-pressed="isPanorama"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.panorama') : undefined"
|
||||
@click="togglePanorama"
|
||||
>
|
||||
<i class="icon-[lucide--globe] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.panorama') }}</span>
|
||||
</button>
|
||||
<Popover v-if="isPanorama">
|
||||
<PopoverTrigger as-child>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.fov'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.fov') : undefined"
|
||||
>
|
||||
<i class="icon-[lucide--focus] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.fov') }}</span>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side="bottom"
|
||||
align="start"
|
||||
:side-offset="8"
|
||||
:class="cn(panelClass, 'w-56')"
|
||||
>
|
||||
<div class="flex flex-col gap-2 p-1">
|
||||
<span class="text-sm text-base-foreground">{{
|
||||
t('load3d.fov')
|
||||
}}</span>
|
||||
<Slider
|
||||
:model-value="[fovValue]"
|
||||
:min="10"
|
||||
:max="150"
|
||||
:step="1"
|
||||
class="w-full"
|
||||
@update:model-value="setFov"
|
||||
/>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<button
|
||||
v-tooltip.bottom="tip(t('load3d.menuBar.removeBackground'))"
|
||||
:class="actionClass(false)"
|
||||
type="button"
|
||||
:aria-label="compact ? t('load3d.menuBar.removeBackground') : undefined"
|
||||
@click="removeBackgroundImage"
|
||||
>
|
||||
<i class="icon-[lucide--x] size-4" />
|
||||
<span v-if="!compact">{{ t('load3d.menuBar.removeBackground') }}</span>
|
||||
</button>
|
||||
</template>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import {
|
||||
actionClass,
|
||||
panelClass,
|
||||
tip
|
||||
} from '@/components/load3d/menubar/menuBarStyles'
|
||||
import Popover from '@/components/ui/popover/Popover.vue'
|
||||
import PopoverContent from '@/components/ui/popover/PopoverContent.vue'
|
||||
import Slider from '@/components/ui/slider/Slider.vue'
|
||||
import type { SceneConfig } from '@/extensions/core/load3d/interfaces'
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
import { PopoverTrigger } from 'reka-ui'
|
||||
|
||||
const {
|
||||
compact = false,
|
||||
canUseBackgroundImage = true,
|
||||
hdriActive = false
|
||||
} = defineProps<{
|
||||
compact?: boolean
|
||||
canUseBackgroundImage?: boolean
|
||||
hdriActive?: boolean
|
||||
}>()
|
||||
|
||||
const config = defineModel<SceneConfig>('config')
|
||||
const fov = defineModel<number>('fov')
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'updateBackgroundImage', file: File | null): void
|
||||
}>()
|
||||
|
||||
const { t } = useI18n()
|
||||
|
||||
const showGrid = computed(() => config.value?.showGrid ?? false)
|
||||
const bgColor = computed(() => config.value?.backgroundColor ?? '#000000')
|
||||
const hasImage = computed(
|
||||
() => !!config.value?.backgroundImage && config.value.backgroundImage !== ''
|
||||
)
|
||||
const isPanorama = computed(
|
||||
() => config.value?.backgroundRenderMode === 'panorama'
|
||||
)
|
||||
const fovValue = computed(() => fov.value ?? 10)
|
||||
|
||||
const colorRef = ref<HTMLInputElement | null>(null)
|
||||
const bgImageRef = ref<HTMLInputElement | null>(null)
|
||||
|
||||
function toggleGrid() {
|
||||
if (config.value) config.value.showGrid = !config.value.showGrid
|
||||
}
|
||||
|
||||
function setBackgroundColor(event: Event) {
|
||||
if (config.value) {
|
||||
config.value.backgroundColor = (event.target as HTMLInputElement).value
|
||||
}
|
||||
}
|
||||
|
||||
function onBackgroundImagePicked(event: Event) {
|
||||
const input = event.target as HTMLInputElement
|
||||
const file = input.files?.[0]
|
||||
input.value = ''
|
||||
if (file) emit('updateBackgroundImage', file)
|
||||
}
|
||||
|
||||
function removeBackgroundImage() {
|
||||
emit('updateBackgroundImage', null)
|
||||
}
|
||||
|
||||
function togglePanorama() {
|
||||
if (!config.value) return
|
||||
config.value.backgroundRenderMode =
|
||||
config.value.backgroundRenderMode === 'panorama' ? 'tiled' : 'panorama'
|
||||
}
|
||||
|
||||
function setFov(value?: number[]) {
|
||||
if (value?.length) fov.value = value[0]
|
||||
}
|
||||
</script>
|
||||
@@ -1,24 +0,0 @@
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
export const chipClass =
|
||||
'flex shrink-0 items-center gap-1.5 rounded-lg border-0 bg-interface-menu-surface px-2.5 py-1 text-sm text-base-foreground outline-none transition-colors hover:bg-button-active-surface focus-visible:ring-1 focus-visible:ring-ring'
|
||||
|
||||
export const iconBtnClass =
|
||||
'flex size-8 items-center justify-center rounded-md border-0 bg-transparent text-base-foreground outline-none transition-colors hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring'
|
||||
|
||||
export const panelClass =
|
||||
'w-48 max-h-80 overflow-y-auto flex flex-col gap-0.5 p-1.5 rounded-lg border-border-default bg-interface-menu-surface shadow-interface'
|
||||
|
||||
export const rowClass =
|
||||
'flex w-full cursor-pointer items-center rounded-md border-0 bg-transparent px-2 py-1.5 text-left text-sm text-base-foreground outline-none hover:bg-button-hover-surface focus-visible:ring-1 focus-visible:ring-ring focus-visible:ring-inset'
|
||||
|
||||
export function actionClass(active: boolean) {
|
||||
return cn(
|
||||
'focus-visible:ring-ring flex shrink-0 items-center gap-1.5 rounded-md border-0 bg-transparent px-2 py-1 text-sm text-base-foreground transition-colors outline-none hover:bg-button-hover-surface focus-visible:ring-1',
|
||||
active && 'bg-button-active-surface'
|
||||
)
|
||||
}
|
||||
|
||||
export function tip(label: string) {
|
||||
return { value: label, showDelay: 300 }
|
||||
}
|
||||
@@ -7,7 +7,7 @@
|
||||
)
|
||||
"
|
||||
>
|
||||
<i class="icon-[lucide--component] h-full bg-amber-400" />
|
||||
<i class="icon-[lucide--coins] h-full bg-amber-400" />
|
||||
<span class="truncate" v-text="text" />
|
||||
</span>
|
||||
<span
|
||||
|
||||
@@ -32,7 +32,7 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('appends a color when the add button is clicked', async () => {
|
||||
const { emitted } = renderRow(['#ff0000'])
|
||||
await userEvent.click(screen.getByRole('button', { name: '+' }))
|
||||
await userEvent.click(screen.getByRole('button'))
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#ffffff'])
|
||||
})
|
||||
|
||||
@@ -44,14 +44,18 @@ describe('PaletteSwatchRow', () => {
|
||||
|
||||
it('hides the add button once the max is reached', () => {
|
||||
renderRow(['#a', '#b'], 2)
|
||||
expect(screen.queryByRole('button', { name: '+' })).toBeNull()
|
||||
expect(screen.queryByRole('button')).toBeNull()
|
||||
})
|
||||
|
||||
it('opens the color picker when a swatch is clicked', async () => {
|
||||
const { container } = renderRow(['#ff0000'])
|
||||
const swatch = container.querySelector('[data-index="0"]')!
|
||||
await userEvent.click(swatch)
|
||||
expect(swatch.getAttribute('data-state')).toBe('open')
|
||||
it('writes a picked color back through the hidden color input', async () => {
|
||||
const { container, emitted } = renderRow(['#ff0000', '#00ff00'])
|
||||
await fireEvent.click(container.querySelector('[data-index="1"]')!)
|
||||
const input = container.querySelector(
|
||||
'input[type="color"]'
|
||||
) as HTMLInputElement
|
||||
input.value = '#0000ff'
|
||||
await fireEvent.input(input)
|
||||
expect(lastEmit(emitted)).toEqual(['#ff0000', '#0000ff'])
|
||||
})
|
||||
|
||||
it('starts a drag on pointer down without emitting', async () => {
|
||||
|
||||
@@ -1,25 +1,17 @@
|
||||
<template>
|
||||
<div ref="container" class="flex flex-wrap items-center gap-1">
|
||||
<ColorPicker
|
||||
<div
|
||||
v-for="(hex, i) in modelValue"
|
||||
:key="i"
|
||||
:model-value="hex"
|
||||
:alpha="false"
|
||||
@update:model-value="(value) => updateAt(i, value)"
|
||||
>
|
||||
<template #trigger>
|
||||
<button
|
||||
type="button"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border p-0"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
</template>
|
||||
</ColorPicker>
|
||||
:key="`${i}-${hex}`"
|
||||
:data-index="i"
|
||||
:data-hex="hex"
|
||||
class="relative size-5 cursor-pointer rounded-sm border border-component-node-border"
|
||||
:style="{ background: hex }"
|
||||
:title="t('palette.swatchTitle')"
|
||||
@click="openPicker(i, $event)"
|
||||
@contextmenu.prevent.stop="remove(i)"
|
||||
@pointerdown="onPointerDown(i, $event)"
|
||||
/>
|
||||
<button
|
||||
v-if="modelValue.length < max"
|
||||
type="button"
|
||||
@@ -29,6 +21,12 @@
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<input
|
||||
ref="picker"
|
||||
type="color"
|
||||
class="pointer-events-none absolute size-0 opacity-0"
|
||||
@input="onPickerInput"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -36,7 +34,6 @@
|
||||
import { useTemplateRef } from 'vue'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
|
||||
import ColorPicker from '@/components/ui/color-picker/ColorPicker.vue'
|
||||
import { usePaletteSwatchRow } from '@/composables/palette/usePaletteSwatchRow'
|
||||
|
||||
const { max = 5 } = defineProps<{ max?: number }>()
|
||||
@@ -44,9 +41,8 @@ const modelValue = defineModel<string[]>({ required: true })
|
||||
const { t } = useI18n()
|
||||
|
||||
const container = useTemplateRef<HTMLDivElement>('container')
|
||||
const picker = useTemplateRef<HTMLInputElement>('picker')
|
||||
|
||||
const { updateAt, remove, addColor, onPointerDown } = usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container
|
||||
})
|
||||
const { openPicker, onPickerInput, remove, addColor, onPointerDown } =
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
</script>
|
||||
|
||||
@@ -51,7 +51,7 @@
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="icon-[lucide--component] size-3 text-amber-400"
|
||||
class="icon-[lucide--coins] size-3 text-amber-400"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
|
||||
<!-- Credits Section -->
|
||||
<div v-if="isActiveSubscription" class="flex items-center gap-2 px-4 py-2">
|
||||
<i class="icon-[lucide--component] text-sm text-amber-400" />
|
||||
<i class="icon-[lucide--coins] text-sm text-amber-400" />
|
||||
<Skeleton v-if="isLoading" width="4rem" height="1.25rem" class="w-full" />
|
||||
<span v-else class="text-base font-semibold text-base-foreground">{{
|
||||
formattedBalance
|
||||
|
||||
@@ -14,27 +14,20 @@ import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import ColorPickerPanel from './ColorPickerPanel.vue'
|
||||
|
||||
const { alpha = true } = defineProps<{
|
||||
defineProps<{
|
||||
class?: string
|
||||
disabled?: boolean
|
||||
alpha?: boolean
|
||||
}>()
|
||||
|
||||
const modelValue = defineModel<string>({ default: '#000000' })
|
||||
|
||||
function readHsva(hex: string): HSVA {
|
||||
const next = hexToHsva(hex || '#000000')
|
||||
if (!alpha) next.a = 100
|
||||
return next
|
||||
}
|
||||
|
||||
const hsva = ref<HSVA>(readHsva(modelValue.value))
|
||||
const hsva = ref<HSVA>(hexToHsva(modelValue.value || '#000000'))
|
||||
const displayMode = ref<'hex' | 'rgba'>('hex')
|
||||
|
||||
watch(modelValue, (newVal) => {
|
||||
const current = hsvaToHex(hsva.value)
|
||||
if (newVal !== current) {
|
||||
hsva.value = readHsva(newVal)
|
||||
hsva.value = hexToHsva(newVal || '#000000')
|
||||
}
|
||||
})
|
||||
|
||||
@@ -74,51 +67,49 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<template>
|
||||
<PopoverRoot v-model:open="isOpen">
|
||||
<PopoverTrigger as-child>
|
||||
<slot name="trigger">
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
<button
|
||||
type="button"
|
||||
:disabled="$props.disabled"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-8 w-full items-center overflow-clip rounded-lg border border-transparent bg-component-node-widget-background pr-2 outline-none hover:bg-component-node-widget-background-hovered disabled:cursor-not-allowed disabled:opacity-50',
|
||||
isOpen && 'border-node-stroke',
|
||||
$props.class
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
>
|
||||
<div class="flex size-8 shrink-0 items-center justify-center">
|
||||
<div class="relative size-4 overflow-hidden rounded-sm">
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{
|
||||
backgroundImage:
|
||||
'repeating-conic-gradient(#808080 0% 25%, transparent 0% 50%)',
|
||||
backgroundSize: '4px 4px'
|
||||
}"
|
||||
/>
|
||||
<div
|
||||
class="absolute inset-0"
|
||||
:style="{ backgroundColor: previewColor }"
|
||||
/>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="flex flex-1 items-center justify-between pl-1 text-xs text-component-node-foreground"
|
||||
>
|
||||
<template v-if="displayMode === 'hex'">
|
||||
<span>{{ displayHex }}</span>
|
||||
</template>
|
||||
<template v-else>
|
||||
<div class="flex gap-2">
|
||||
<span>{{ baseRgb.r }}</span>
|
||||
<span>{{ baseRgb.g }}</span>
|
||||
<span>{{ baseRgb.b }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</slot>
|
||||
</template>
|
||||
<span>{{ hsva.a }}%</span>
|
||||
</div>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverPortal>
|
||||
<PopoverContent
|
||||
@@ -132,7 +123,6 @@ const contentStyle = useModalLiftedZIndex(isOpen)
|
||||
<ColorPickerPanel
|
||||
v-model:hsva="hsva"
|
||||
v-model:display-mode="displayMode"
|
||||
:alpha
|
||||
/>
|
||||
</PopoverContent>
|
||||
</PopoverPortal>
|
||||
|
||||
@@ -13,8 +13,6 @@ import { hsbToRgb, rgbToHex } from '@/utils/colorUtil'
|
||||
import ColorPickerSaturationValue from './ColorPickerSaturationValue.vue'
|
||||
import ColorPickerSlider from './ColorPickerSlider.vue'
|
||||
|
||||
const { alpha = true } = defineProps<{ alpha?: boolean }>()
|
||||
|
||||
const hsva = defineModel<HSVA>('hsva', { required: true })
|
||||
const displayMode = defineModel<'hex' | 'rgba'>('displayMode', {
|
||||
required: true
|
||||
@@ -39,7 +37,6 @@ const { t } = useI18n()
|
||||
/>
|
||||
<ColorPickerSlider v-model="hsva.h" type="hue" />
|
||||
<ColorPickerSlider
|
||||
v-if="alpha"
|
||||
v-model="hsva.a"
|
||||
type="alpha"
|
||||
:hue="hsva.h"
|
||||
@@ -75,7 +72,7 @@ const { t } = useI18n()
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.g }}</span>
|
||||
<span class="w-6 shrink-0 text-center">{{ rgb.b }}</span>
|
||||
</template>
|
||||
<span v-if="alpha" class="shrink-0 border-l border-border-subtle pl-1"
|
||||
<span class="shrink-0 border-l border-border-subtle pl-1"
|
||||
>{{ hsva.a }}%</span
|
||||
>
|
||||
</div>
|
||||
|
||||
22
src/components/ui/hover-card/HoverCard.vue
Normal file
22
src/components/ui/hover-card/HoverCard.vue
Normal file
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { HoverCardRootEmits, HoverCardRootProps } from 'reka-ui'
|
||||
import { provide, ref } from 'vue'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<HoverCardRootProps>()
|
||||
const emits = defineEmits<HoverCardRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
|
||||
const isOpen = ref(false)
|
||||
provide(hoverCardOpenKey, isOpen)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardRoot v-bind="forwarded" v-model:open="isOpen">
|
||||
<slot />
|
||||
</HoverCardRoot>
|
||||
</template>
|
||||
51
src/components/ui/hover-card/HoverCardContent.vue
Normal file
51
src/components/ui/hover-card/HoverCardContent.vue
Normal file
@@ -0,0 +1,51 @@
|
||||
<script setup lang="ts">
|
||||
import { ZIndex } from '@primeuix/utils/zindex'
|
||||
import { HoverCardContent, HoverCardPortal, useForwardProps } from 'reka-ui'
|
||||
import type { HoverCardContentProps } from 'reka-ui'
|
||||
import { computed, inject } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
import { hoverCardOpenKey } from './hoverCardContext'
|
||||
|
||||
// Shared base for @primeuix's auto-incrementing 'modal' z-index counter.
|
||||
const MODAL_BASE_Z_INDEX = 1700
|
||||
|
||||
const {
|
||||
class: className,
|
||||
side = 'bottom',
|
||||
sideOffset = 8,
|
||||
...rest
|
||||
} = defineProps<HoverCardContentProps & { class?: HTMLAttributes['class'] }>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
|
||||
// Body-portaled content sits at a static z-1700 unless a dialog that joined
|
||||
// @primeuix's 'modal' counter is open above it; then lift past that dialog.
|
||||
const open = inject(hoverCardOpenKey, undefined)
|
||||
const contentStyle = computed(() => {
|
||||
if (!open?.value) return undefined
|
||||
const topZIndex = ZIndex.getCurrent('modal')
|
||||
return topZIndex >= MODAL_BASE_Z_INDEX ? { zIndex: topZIndex + 1 } : undefined
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardPortal>
|
||||
<HoverCardContent
|
||||
v-bind="forwarded"
|
||||
:side
|
||||
:side-offset
|
||||
:style="contentStyle"
|
||||
:class="
|
||||
cn(
|
||||
'z-1700 rounded-lg border border-border-subtle bg-secondary-background p-2.5 shadow-md outline-none',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</HoverCardContent>
|
||||
</HoverCardPortal>
|
||||
</template>
|
||||
12
src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
12
src/components/ui/hover-card/HoverCardTrigger.vue
Normal file
@@ -0,0 +1,12 @@
|
||||
<script setup lang="ts">
|
||||
import { HoverCardTrigger } from 'reka-ui'
|
||||
import type { HoverCardTriggerProps } from 'reka-ui'
|
||||
|
||||
const props = defineProps<HoverCardTriggerProps>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<HoverCardTrigger v-bind="props">
|
||||
<slot />
|
||||
</HoverCardTrigger>
|
||||
</template>
|
||||
7
src/components/ui/hover-card/hoverCardContext.ts
Normal file
7
src/components/ui/hover-card/hoverCardContext.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import type { InjectionKey, Ref } from 'vue'
|
||||
|
||||
// Shares the root open-state with the content so it can lift its z-index above
|
||||
// a dialog that joined @primeuix's incrementing 'modal' counter (otherwise the
|
||||
// body-portaled content renders behind the settings dialog).
|
||||
export const hoverCardOpenKey: InjectionKey<Ref<boolean>> =
|
||||
Symbol('hoverCardOpen')
|
||||
72
src/components/ui/pagination/Pagination.vue
Normal file
72
src/components/ui/pagination/Pagination.vue
Normal file
@@ -0,0 +1,72 @@
|
||||
<template>
|
||||
<PaginationRoot
|
||||
:page="page"
|
||||
:total="total"
|
||||
:items-per-page="itemsPerPage"
|
||||
:sibling-count="1"
|
||||
show-edges
|
||||
@update:page="(p: number) => emit('update:page', p)"
|
||||
>
|
||||
<div class="flex items-center gap-1">
|
||||
<PaginationPrev as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
<i class="icon-[lucide--chevron-left] size-4" />
|
||||
{{ $t('g.previous') }}
|
||||
</Button>
|
||||
</PaginationPrev>
|
||||
<PaginationList v-slot="{ items }" class="flex items-center gap-1">
|
||||
<template v-for="(item, index) in items" :key="index">
|
||||
<PaginationListItem
|
||||
v-if="item.type === 'page'"
|
||||
:value="item.value"
|
||||
as-child
|
||||
>
|
||||
<Button
|
||||
:variant="item.value === page ? 'secondary' : 'muted-textonly'"
|
||||
size="icon"
|
||||
>
|
||||
{{ item.value }}
|
||||
</Button>
|
||||
</PaginationListItem>
|
||||
<PaginationEllipsis v-else :index="index" :class="ellipsisClass">
|
||||
…
|
||||
</PaginationEllipsis>
|
||||
</template>
|
||||
</PaginationList>
|
||||
<PaginationNext as-child>
|
||||
<Button variant="muted-textonly" size="md" class="text-sm">
|
||||
{{ $t('g.next') }}
|
||||
<i class="icon-[lucide--chevron-right] size-4" />
|
||||
</Button>
|
||||
</PaginationNext>
|
||||
</div>
|
||||
</PaginationRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
PaginationEllipsis,
|
||||
PaginationList,
|
||||
PaginationListItem,
|
||||
PaginationNext,
|
||||
PaginationPrev,
|
||||
PaginationRoot
|
||||
} from 'reka-ui'
|
||||
|
||||
import Button from '@/components/ui/button/Button.vue'
|
||||
|
||||
const {
|
||||
page = 1,
|
||||
total,
|
||||
itemsPerPage = 10
|
||||
} = defineProps<{
|
||||
page?: number
|
||||
total: number
|
||||
itemsPerPage?: number
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:page': [page: number] }>()
|
||||
|
||||
const ellipsisClass =
|
||||
'inline-flex size-8 items-center justify-center text-sm text-muted-foreground'
|
||||
</script>
|
||||
@@ -35,7 +35,7 @@ export const searchInputSizeConfig = {
|
||||
icon: 'size-4',
|
||||
iconPos: 'left-2.5',
|
||||
inputPl: 'pl-8',
|
||||
inputText: 'text-xs',
|
||||
inputText: 'text-sm',
|
||||
clearPos: 'left-2.5'
|
||||
},
|
||||
xl: {
|
||||
|
||||
30
src/components/ui/switch/Switch.vue
Normal file
30
src/components/ui/switch/Switch.vue
Normal file
@@ -0,0 +1,30 @@
|
||||
<template>
|
||||
<SwitchRoot
|
||||
v-model="checked"
|
||||
:disabled
|
||||
:class="
|
||||
cn(
|
||||
'inline-flex h-5 w-9 shrink-0 cursor-pointer items-center rounded-full border border-transparent px-0.5 transition-colors focus-visible:ring-2 focus-visible:ring-primary/50 focus-visible:outline-none disabled:cursor-not-allowed disabled:opacity-50',
|
||||
checked ? 'bg-primary' : 'bg-interface-stroke'
|
||||
)
|
||||
"
|
||||
>
|
||||
<SwitchThumb
|
||||
:class="
|
||||
cn(
|
||||
'pointer-events-none block size-4 rounded-full bg-white shadow-sm transition-transform',
|
||||
checked ? 'translate-x-3.5' : 'translate-x-0'
|
||||
)
|
||||
"
|
||||
/>
|
||||
</SwitchRoot>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { SwitchRoot, SwitchThumb } from 'reka-ui'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { disabled = false } = defineProps<{ disabled?: boolean }>()
|
||||
const checked = defineModel<boolean>({ default: false })
|
||||
</script>
|
||||
17
src/components/ui/table/Table.vue
Normal file
17
src/components/ui/table/Table.vue
Normal file
@@ -0,0 +1,17 @@
|
||||
<template>
|
||||
<div :class="cn('relative w-full overflow-auto', className)">
|
||||
<table
|
||||
class="w-full caption-bottom border-separate border-spacing-0 text-sm"
|
||||
>
|
||||
<slot />
|
||||
</table>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableBody.vue
Normal file
13
src/components/ui/table/TableBody.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<tbody :class="cn('[&_tr:last-child]:border-0', className)">
|
||||
<slot />
|
||||
</tbody>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
13
src/components/ui/table/TableCell.vue
Normal file
13
src/components/ui/table/TableCell.vue
Normal file
@@ -0,0 +1,13 @@
|
||||
<template>
|
||||
<td :class="cn('px-2 py-2.5 align-middle whitespace-nowrap', className)">
|
||||
<slot />
|
||||
</td>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
21
src/components/ui/table/TableHead.vue
Normal file
21
src/components/ui/table/TableHead.vue
Normal file
@@ -0,0 +1,21 @@
|
||||
<template>
|
||||
<th
|
||||
scope="col"
|
||||
:class="
|
||||
cn(
|
||||
'h-10 px-2 text-left align-middle text-sm font-normal whitespace-nowrap text-muted-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</th>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
15
src/components/ui/table/TableHeader.vue
Normal file
15
src/components/ui/table/TableHeader.vue
Normal file
@@ -0,0 +1,15 @@
|
||||
<template>
|
||||
<thead
|
||||
:class="cn('[&_tr]:border-b [&_tr]:border-interface-stroke/60', className)"
|
||||
>
|
||||
<slot />
|
||||
</thead>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
20
src/components/ui/table/TableRow.vue
Normal file
20
src/components/ui/table/TableRow.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<template>
|
||||
<tr
|
||||
:class="
|
||||
cn(
|
||||
'border-b border-interface-stroke/60 transition-colors hover:bg-secondary-background/50 data-[state=selected]:bg-secondary-background/50',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</tr>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className } = defineProps<{ class?: HTMLAttributes['class'] }>()
|
||||
</script>
|
||||
16
src/components/ui/tabs/Tabs.vue
Normal file
16
src/components/ui/tabs/Tabs.vue
Normal file
@@ -0,0 +1,16 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsRoot, useForwardPropsEmits } from 'reka-ui'
|
||||
import type { TabsRootEmits, TabsRootProps } from 'reka-ui'
|
||||
|
||||
// eslint-disable-next-line vue/no-unused-properties -- forwarded to Reka via useForwardPropsEmits
|
||||
const props = defineProps<TabsRootProps>()
|
||||
const emits = defineEmits<TabsRootEmits>()
|
||||
|
||||
const forwarded = useForwardPropsEmits(props, emits)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsRoot v-bind="forwarded">
|
||||
<slot />
|
||||
</TabsRoot>
|
||||
</template>
|
||||
20
src/components/ui/tabs/TabsList.vue
Normal file
20
src/components/ui/tabs/TabsList.vue
Normal file
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsList } from 'reka-ui'
|
||||
import type { TabsListProps } from 'reka-ui'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsListProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsList
|
||||
v-bind="rest"
|
||||
:class="cn('inline-flex items-center gap-4', className)"
|
||||
>
|
||||
<slot />
|
||||
</TabsList>
|
||||
</template>
|
||||
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
28
src/components/ui/tabs/TabsTrigger.vue
Normal file
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { TabsTrigger, useForwardProps } from 'reka-ui'
|
||||
import type { TabsTriggerProps } from 'reka-ui'
|
||||
import { computed } from 'vue'
|
||||
import type { HTMLAttributes } from 'vue'
|
||||
|
||||
import { cn } from '@comfyorg/tailwind-utils'
|
||||
|
||||
const { class: className, ...rest } = defineProps<
|
||||
TabsTriggerProps & { class?: HTMLAttributes['class'] }
|
||||
>()
|
||||
|
||||
const forwarded = useForwardProps(computed(() => rest))
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<TabsTrigger
|
||||
v-bind="forwarded"
|
||||
:class="
|
||||
cn(
|
||||
'cursor-pointer appearance-none border-0 border-b-2 border-transparent bg-transparent px-0 pb-2 text-sm text-muted-foreground transition-colors outline-none data-[state=active]:border-base-foreground data-[state=active]:text-base-foreground',
|
||||
className
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot />
|
||||
</TabsTrigger>
|
||||
</template>
|
||||
@@ -14,7 +14,12 @@
|
||||
>
|
||||
<header
|
||||
data-component-id="LeftPanelHeader"
|
||||
class="flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full shrink-0 items-center-safe gap-2 pr-3 pl-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<slot name="leftPanelHeaderTitle" />
|
||||
<Button
|
||||
@@ -33,7 +38,12 @@
|
||||
<div class="flex flex-col overflow-hidden bg-base-background">
|
||||
<header
|
||||
v-if="$slots.header"
|
||||
class="flex h-18 w-full items-center justify-between gap-2 px-6"
|
||||
:class="
|
||||
cn(
|
||||
'flex h-18 w-full items-center justify-between gap-2 px-6',
|
||||
headerHeightClass
|
||||
)
|
||||
"
|
||||
>
|
||||
<div class="flex min-w-0 flex-1 gap-2">
|
||||
<Button
|
||||
@@ -151,20 +161,22 @@ const SIZE_CLASSES = {
|
||||
} as const
|
||||
|
||||
type ModalSize = keyof typeof SIZE_CLASSES
|
||||
type ContentPadding = 'default' | 'compact' | 'none'
|
||||
type ContentPadding = 'default' | 'compact' | 'none' | 'flush'
|
||||
|
||||
const {
|
||||
contentTitle,
|
||||
rightPanelTitle,
|
||||
size = 'lg',
|
||||
leftPanelWidth = '14rem',
|
||||
contentPadding = 'default'
|
||||
contentPadding = 'default',
|
||||
headerHeightClass = 'h-18'
|
||||
} = defineProps<{
|
||||
contentTitle: string
|
||||
rightPanelTitle?: string
|
||||
size?: ModalSize
|
||||
leftPanelWidth?: string
|
||||
contentPadding?: ContentPadding
|
||||
headerHeightClass?: string
|
||||
}>()
|
||||
|
||||
const sizeClasses = computed(() => SIZE_CLASSES[size])
|
||||
@@ -204,7 +216,10 @@ const contentContainerClass = computed(() =>
|
||||
cn(
|
||||
'flex scrollbar-custom min-h-0 flex-1 flex-col overflow-y-auto',
|
||||
contentPadding === 'default' && 'px-6 pt-0 pb-10',
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2'
|
||||
contentPadding === 'compact' && 'px-6 pt-0 pb-2',
|
||||
// Keep the horizontal inset but let content run to the bottom edge (it
|
||||
// clips there instead of ending above a padding gap).
|
||||
contentPadding === 'flush' && 'px-6 pt-0'
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -107,6 +107,8 @@ export interface BillingState {
|
||||
|
||||
export interface BillingContext extends BillingState, BillingActions {
|
||||
type: ComputedRef<BillingType>
|
||||
/** Subscription paused on a failed payment (`subscriptionStatus === 'paused'`). */
|
||||
isPaused: ComputedRef<boolean>
|
||||
/**
|
||||
* True when the active team workspace is still on a pre-credit-slider
|
||||
* (legacy) per-member tier plan, which keeps the old team pricing table.
|
||||
|
||||
@@ -147,6 +147,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
const subscriptionStatus = computed(() =>
|
||||
toValue(activeContext.value.subscriptionStatus)
|
||||
)
|
||||
const isPaused = computed(() => subscriptionStatus.value === 'paused')
|
||||
const tier = computed(() => toValue(activeContext.value.tier))
|
||||
const renewalDate = computed(() => toValue(activeContext.value.renewalDate))
|
||||
|
||||
@@ -301,6 +302,7 @@ function useBillingContextInternal(): BillingContext {
|
||||
isLegacyTeamPlan,
|
||||
billingStatus,
|
||||
subscriptionStatus,
|
||||
isPaused,
|
||||
tier,
|
||||
renewalDate,
|
||||
getMaxSeats,
|
||||
|
||||
@@ -156,7 +156,7 @@ describe('fromBoundingBoxes', () => {
|
||||
y: 200,
|
||||
width: 300,
|
||||
height: 400,
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#ffffff'] }
|
||||
metadata: { type: 'text', text: 'hi', desc: 'd', palette: ['#fff'] }
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 1000, 1000)[0]).toEqual({
|
||||
@@ -167,31 +167,10 @@ describe('fromBoundingBoxes', () => {
|
||||
type: 'text',
|
||||
text: 'hi',
|
||||
desc: 'd',
|
||||
palette: ['#ffffff']
|
||||
palette: ['#fff']
|
||||
})
|
||||
})
|
||||
|
||||
it('normalizes palette entries and drops invalid colors', () => {
|
||||
const boxes: BoundingBox[] = [
|
||||
{
|
||||
x: 0,
|
||||
y: 0,
|
||||
width: 10,
|
||||
height: 10,
|
||||
metadata: {
|
||||
type: 'obj',
|
||||
text: '',
|
||||
desc: '',
|
||||
palette: ['#FF0000', '#abc', 'red', '', 123] as unknown as string[]
|
||||
}
|
||||
}
|
||||
]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0].palette).toEqual([
|
||||
'#ff0000',
|
||||
'#aabbcc'
|
||||
])
|
||||
})
|
||||
|
||||
it('fills defaults when metadata is missing or partial', () => {
|
||||
const boxes = [{ x: 0, y: 0, width: 10, height: 10 }] as BoundingBox[]
|
||||
expect(fromBoundingBoxes(boxes, 100, 100)[0]).toMatchObject({
|
||||
|
||||
@@ -202,22 +202,6 @@ function isBoundingBox(b: unknown): b is BoundingBox {
|
||||
)
|
||||
}
|
||||
|
||||
function normalizeHexColor(color: unknown): string | null {
|
||||
if (typeof color !== 'string') return null
|
||||
const hex = color.trim().toLowerCase()
|
||||
const short = /^#([0-9a-f])([0-9a-f])([0-9a-f])$/.exec(hex)
|
||||
if (short) {
|
||||
return `#${short[1]}${short[1]}${short[2]}${short[2]}${short[3]}${short[3]}`
|
||||
}
|
||||
return /^#([0-9a-f]{6}|[0-9a-f]{8})$/.test(hex) ? hex : null
|
||||
}
|
||||
|
||||
function normalizePalette(palette: unknown): string[] {
|
||||
return Array.isArray(palette)
|
||||
? palette.map(normalizeHexColor).filter((c): c is string => c !== null)
|
||||
: []
|
||||
}
|
||||
|
||||
export function fromBoundingBoxes(
|
||||
boxes: readonly BoundingBox[],
|
||||
width: number,
|
||||
@@ -235,7 +219,9 @@ export function fromBoundingBoxes(
|
||||
type: meta.type === 'text' ? 'text' : 'obj',
|
||||
text: typeof meta.text === 'string' ? meta.text : '',
|
||||
desc: typeof meta.desc === 'string' ? meta.desc : '',
|
||||
palette: normalizePalette(meta.palette)
|
||||
palette: Array.isArray(meta.palette)
|
||||
? meta.palette.filter((c): c is string => typeof c === 'string')
|
||||
: []
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -8,32 +8,14 @@ import { useBoundingBoxes } from './useBoundingBoxes'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
import { toNodeId } from '@/types/nodeId'
|
||||
|
||||
const { appState, outputState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown },
|
||||
outputState: {
|
||||
outputs: undefined as unknown,
|
||||
nodeOutputs: null as { value: Record<string, unknown> } | null
|
||||
}
|
||||
const { appState } = vi.hoisted(() => ({
|
||||
appState: { node: null as unknown }
|
||||
}))
|
||||
|
||||
vi.mock('@/scripts/app', () => ({
|
||||
app: { canvas: { graph: { getNodeById: () => appState.node } } }
|
||||
}))
|
||||
|
||||
vi.mock('@/stores/nodeOutputStore', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const nodeOutputs = ref<Record<string, unknown>>({})
|
||||
outputState.nodeOutputs = nodeOutputs
|
||||
return {
|
||||
useNodeOutputStore: () => ({
|
||||
nodeOutputs,
|
||||
nodePreviewImages: ref({}),
|
||||
getNodeImageUrls: () => undefined,
|
||||
getNodeOutputs: () => outputState.outputs
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const ctx = {
|
||||
measureText: (s: string) => ({ width: s.length * 7 }),
|
||||
setTransform: () => {},
|
||||
@@ -45,9 +27,6 @@ const ctx = {
|
||||
save: () => {},
|
||||
restore: () => {},
|
||||
beginPath: () => {},
|
||||
moveTo: () => {},
|
||||
arc: () => {},
|
||||
fill: () => {},
|
||||
rect: () => {},
|
||||
clip: () => {},
|
||||
font: '',
|
||||
@@ -79,32 +58,17 @@ function makeCanvas(): HTMLCanvasElement {
|
||||
return el
|
||||
}
|
||||
|
||||
interface MockNode {
|
||||
widgets: { name: string; value: unknown }[]
|
||||
findInputSlot: (name: string) => number
|
||||
getInputNode: () => null
|
||||
isInputConnected?: () => boolean
|
||||
}
|
||||
|
||||
function makeNode(): MockNode {
|
||||
function makeNode() {
|
||||
return {
|
||||
widgets: [
|
||||
{ name: 'width', value: 512 },
|
||||
{ name: 'height', value: 512 },
|
||||
{ name: 'last_incoming', value: [] }
|
||||
{ name: 'height', value: 512 }
|
||||
],
|
||||
findInputSlot: () => -1,
|
||||
getInputNode: () => null
|
||||
}
|
||||
}
|
||||
|
||||
const lastIncomingOf = (node: MockNode) =>
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value
|
||||
|
||||
const setLastIncomingOf = (node: MockNode, value: BoundingBox[]) => {
|
||||
node.widgets.find((w) => w.name === 'last_incoming')!.value = value
|
||||
}
|
||||
|
||||
const pe = (
|
||||
clientX: number,
|
||||
clientY: number,
|
||||
@@ -132,8 +96,6 @@ interface Captured extends Api {
|
||||
modelValue: Ref<BoundingBox[]>
|
||||
}
|
||||
|
||||
const modelBoxes = (c: Captured) => c.modelValue.value
|
||||
|
||||
function setup(initial: BoundingBox[] = []) {
|
||||
let captured: Captured | undefined
|
||||
const Harness = defineComponent({
|
||||
@@ -166,19 +128,9 @@ const box = (over: Partial<BoundingBox> = {}): BoundingBox => ({
|
||||
...over
|
||||
})
|
||||
|
||||
function makeConnectedNode(): MockNode {
|
||||
return {
|
||||
...makeNode(),
|
||||
findInputSlot: (name: string) => (name === 'bboxes' ? 1 : -1),
|
||||
isInputConnected: () => true
|
||||
}
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
appState.node = makeNode()
|
||||
outputState.outputs = undefined
|
||||
if (outputState.nodeOutputs) outputState.nodeOutputs.value = {}
|
||||
vi.stubGlobal('requestAnimationFrame', (cb: FrameRequestCallback) => {
|
||||
void Promise.resolve().then(() => cb(0))
|
||||
return 1
|
||||
@@ -216,8 +168,8 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBeGreaterThan(0)
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
expect(c.modelValue.value[0].width).toBeGreaterThan(0)
|
||||
})
|
||||
|
||||
it('discards a zero-size draw', async () => {
|
||||
@@ -225,7 +177,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onDocPointerUp(pe(10, 10))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('selects an existing region instead of drawing when clicking inside it', async () => {
|
||||
@@ -233,7 +185,7 @@ describe('useBoundingBoxes drawing', () => {
|
||||
c.onPointerDown(pe(30, 30))
|
||||
c.onDocPointerUp(pe(30, 30))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(c.modelValue.value).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -242,7 +194,7 @@ describe('useBoundingBoxes region editing', () => {
|
||||
const c = setup([box()])
|
||||
c.setActiveType('text')
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].metadata.type).toBe('text')
|
||||
expect(c.modelValue.value[0].metadata.type).toBe('text')
|
||||
})
|
||||
|
||||
it('deletes the active region on Delete', async () => {
|
||||
@@ -253,18 +205,14 @@ describe('useBoundingBoxes region editing', () => {
|
||||
stopPropagation: () => {}
|
||||
} as unknown as KeyboardEvent)
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('clears all regions and invalidates the applied upstream input', async () => {
|
||||
const node = makeNode()
|
||||
setLastIncomingOf(node, [box()])
|
||||
appState.node = node
|
||||
it('clears all regions', async () => {
|
||||
const c = setup([box(), box({ x: 0 })])
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
expect(lastIncomingOf(node)).toEqual([])
|
||||
expect(c.modelValue.value).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -278,7 +226,7 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
c.inlineEditor.value!.value = 'a label'
|
||||
c.commitInlineEditor()
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].metadata.desc).toBe('a label')
|
||||
expect(c.modelValue.value[0].metadata.desc).toBe('a label')
|
||||
expect(c.inlineEditor.value).toBeNull()
|
||||
})
|
||||
|
||||
@@ -291,168 +239,6 @@ describe('useBoundingBoxes inline editor', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes incoming bboxes input', () => {
|
||||
it('adopts cached outputs on mount without overwriting existing edits', () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('does not re-apply an already applied output after a remount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
setLastIncomingOf(node, incoming)
|
||||
appState.node = node
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(300)
|
||||
})
|
||||
|
||||
it('ignores incoming output when the input is not connected', () => {
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('repopulates from the next run after clearing the canvas', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
})
|
||||
|
||||
it('does not apply output updates while the input is disconnected', async () => {
|
||||
let connected = true
|
||||
appState.node = {
|
||||
...makeConnectedNode(),
|
||||
isInputConnected: () => connected
|
||||
}
|
||||
const c = setup([])
|
||||
|
||||
outputState.outputs = { input_bboxes: [box({ x: 0, width: 100 })] }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
|
||||
c.clearAll()
|
||||
await flush()
|
||||
connected = false
|
||||
outputState.nodeOutputs!.value = { n: 2 }
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
|
||||
it('does not apply incoming boxes while the user is drawing', async () => {
|
||||
appState.node = makeConnectedNode()
|
||||
const c = setup([])
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(50, 50))
|
||||
|
||||
outputState.outputs = {
|
||||
input_bboxes: [box({ x: 0, width: 100, height: 100 })]
|
||||
}
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
c.onDocPointerUp(pe(50, 50))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(205)
|
||||
})
|
||||
|
||||
it('applies incoming boxes when outputs stream in after mount', async () => {
|
||||
const node = makeConnectedNode()
|
||||
appState.node = node
|
||||
const c = setup([])
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
|
||||
const incoming = [box({ x: 0, width: 100 })]
|
||||
outputState.outputs = { input_bboxes: incoming }
|
||||
outputState.nodeOutputs!.value = { updated: true }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].width).toBe(100)
|
||||
expect(lastIncomingOf(node)).toEqual(incoming)
|
||||
})
|
||||
|
||||
it('re-seeds the canvas over user edits when the upstream value changes', async () => {
|
||||
const node = makeConnectedNode()
|
||||
setLastIncomingOf(node, [box({ x: 0, width: 100 })])
|
||||
appState.node = node
|
||||
const c = setup([box({ x: 200, width: 300 })])
|
||||
|
||||
const changed = [box({ x: 64, width: 128 })]
|
||||
outputState.outputs = { input_bboxes: changed }
|
||||
outputState.nodeOutputs!.value = { n: 1 }
|
||||
await flush()
|
||||
|
||||
expect(modelBoxes(c)[0].width).toBe(128)
|
||||
expect(lastIncomingOf(node)).toEqual(changed)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes grid snapping', () => {
|
||||
it('snaps a drawn box to the grid when grid is enabled (default)', async () => {
|
||||
const c = setup()
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(60, 60))
|
||||
c.onDocPointerUp(pe(60, 60))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(1)
|
||||
expect(modelBoxes(c)[0].x).toBe(64)
|
||||
expect(modelBoxes(c)[0].width).toBe(256)
|
||||
})
|
||||
|
||||
it('does not snap when grid is disabled', async () => {
|
||||
const c = setup()
|
||||
c.grid.value = false
|
||||
c.onPointerDown(pe(10, 10))
|
||||
c.onCanvasPointerMove(pe(55, 55))
|
||||
c.onDocPointerUp(pe(55, 55))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].width).toBe(230)
|
||||
})
|
||||
|
||||
it('keeps the anchored edge fixed when resizing a single edge', async () => {
|
||||
const c = setup([box({ x: 51, y: 51, width: 256, height: 256 })])
|
||||
c.onPointerDown(pe(60, 30))
|
||||
c.onCanvasPointerMove(pe(80, 30))
|
||||
c.onDocPointerUp(pe(80, 30))
|
||||
await flush()
|
||||
expect(modelBoxes(c)[0].x).toBe(51)
|
||||
})
|
||||
|
||||
it('removes a box that a resize collapses to zero size', async () => {
|
||||
const c = setup([box({ x: 64, y: 64, width: 128, height: 128 })])
|
||||
c.onPointerDown(pe(37, 25))
|
||||
c.onCanvasPointerMove(pe(14, 25))
|
||||
c.onDocPointerUp(pe(14, 25))
|
||||
await flush()
|
||||
expect(modelBoxes(c)).toHaveLength(0)
|
||||
})
|
||||
})
|
||||
|
||||
describe('useBoundingBoxes hover cursor', () => {
|
||||
it('switches to a pointer cursor over a tag', async () => {
|
||||
const c = setup([box({ x: 10, y: 10, width: 256, height: 256 })])
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { useElementSize } from '@vueuse/core'
|
||||
import { cloneDeep, isEqual } from 'es-toolkit'
|
||||
import { storeToRefs } from 'pinia'
|
||||
import type { Ref, ShallowRef } from 'vue'
|
||||
import { computed, nextTick, onBeforeUnmount, ref, watch } from 'vue'
|
||||
@@ -16,7 +15,6 @@ import type {
|
||||
Region
|
||||
} from '@/composables/boundingBoxes/boundingBoxesUtil'
|
||||
import { useCanvasStore } from '@/renderer/core/canvas/canvasStore'
|
||||
import type { NodeOutputWith } from '@/schemas/apiSchema'
|
||||
import { app } from '@/scripts/app'
|
||||
import { useNodeOutputStore } from '@/stores/nodeOutputStore'
|
||||
import type { BoundingBox } from '@/types/boundingBoxes'
|
||||
@@ -27,10 +25,6 @@ const HANDLE_PX = 8
|
||||
const DIMENSION_STEP = 16
|
||||
const BG_DIM = 0.75
|
||||
const MAX_ELEMENT_COLORS = 5
|
||||
const GRID_PX = 32
|
||||
const MAX_GRID_CELLS = 64
|
||||
const DOT_ALPHA = 0.18
|
||||
const DOT_RADIUS = 1
|
||||
|
||||
interface InlineEditorState {
|
||||
value: string
|
||||
@@ -63,7 +57,6 @@ export function useBoundingBoxes(
|
||||
const hoverTagIndex = ref<number | null>(null)
|
||||
const bgImage = ref<HTMLImageElement | null>(null)
|
||||
const inlineEditor = ref<InlineEditorState | null>(null)
|
||||
const grid = ref(true)
|
||||
|
||||
const { width: containerWidth } = useElementSize(canvasContainer)
|
||||
|
||||
@@ -103,89 +96,6 @@ export function useBoundingBoxes(
|
||||
return Math.max(0, Math.min(1, n))
|
||||
}
|
||||
|
||||
function gridSpec() {
|
||||
const axisFraction = (size: number) =>
|
||||
Math.max(GRID_PX, Math.ceil(size / MAX_GRID_CELLS)) / size
|
||||
return {
|
||||
fx: axisFraction(widthValue.value),
|
||||
fy: axisFraction(heightValue.value)
|
||||
}
|
||||
}
|
||||
|
||||
function snapFraction(value: number, step: number) {
|
||||
return step > 0 ? clampToCanvas(Math.round(value / step) * step) : value
|
||||
}
|
||||
|
||||
function snapRegion(region: Region, mode: HitMode): Region {
|
||||
if (!grid.value) return region
|
||||
const { fx, fy } = gridSpec()
|
||||
if (mode === 'move') {
|
||||
return {
|
||||
...region,
|
||||
x: Math.min(snapFraction(region.x, fx), 1 - region.w),
|
||||
y: Math.min(snapFraction(region.y, fy), 1 - region.h)
|
||||
}
|
||||
}
|
||||
const snapLeft =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-l' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-bl'
|
||||
const snapRight =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-r' ||
|
||||
mode === 'resize-tr' ||
|
||||
mode === 'resize-br'
|
||||
const snapTop =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-t' ||
|
||||
mode === 'resize-tl' ||
|
||||
mode === 'resize-tr'
|
||||
const snapBottom =
|
||||
mode === 'draw' ||
|
||||
mode === 'resize-b' ||
|
||||
mode === 'resize-bl' ||
|
||||
mode === 'resize-br'
|
||||
const x1 = snapLeft ? snapFraction(region.x, fx) : region.x
|
||||
const y1 = snapTop ? snapFraction(region.y, fy) : region.y
|
||||
const x2 = snapRight
|
||||
? snapFraction(region.x + region.w, fx)
|
||||
: region.x + region.w
|
||||
const y2 = snapBottom
|
||||
? snapFraction(region.y + region.h, fy)
|
||||
: region.y + region.h
|
||||
return {
|
||||
...region,
|
||||
x: x1,
|
||||
y: y1,
|
||||
w: Math.max(0, x2 - x1),
|
||||
h: Math.max(0, y2 - y1)
|
||||
}
|
||||
}
|
||||
|
||||
function drawDots(ctx: CanvasRenderingContext2D, W: number, H: number) {
|
||||
const el = canvasEl.value
|
||||
if (!el) return
|
||||
const { fx, fy } = gridSpec()
|
||||
if (fx <= 0 || fy <= 0) return
|
||||
const cols = Math.round(1 / fx)
|
||||
const rows = Math.round(1 / fy)
|
||||
ctx.save()
|
||||
ctx.globalAlpha = DOT_ALPHA
|
||||
ctx.fillStyle = getComputedStyle(el).color
|
||||
ctx.beginPath()
|
||||
for (let i = 0; i <= cols; i++) {
|
||||
const cx = Math.min(1, i * fx) * W
|
||||
for (let j = 0; j <= rows; j++) {
|
||||
const cy = Math.min(1, j * fy) * H
|
||||
ctx.moveTo(cx + DOT_RADIUS, cy)
|
||||
ctx.arc(cx, cy, DOT_RADIUS, 0, Math.PI * 2)
|
||||
}
|
||||
}
|
||||
ctx.fill()
|
||||
ctx.restore()
|
||||
}
|
||||
|
||||
function logicalSize() {
|
||||
const el = canvasEl.value
|
||||
return { w: el?.clientWidth || 1, h: el?.clientHeight || 1 }
|
||||
@@ -236,8 +146,6 @@ export function useBoundingBoxes(
|
||||
ctx.fillRect(0, 0, W, H)
|
||||
}
|
||||
|
||||
if (grid.value) drawDots(ctx, W, H)
|
||||
|
||||
const showActive = focused.value || isNodeSelected.value
|
||||
const aIdx = showActive ? activeIndex.value : -1
|
||||
const order = state.value.regions
|
||||
@@ -458,7 +366,7 @@ export function useBoundingBoxes(
|
||||
const dx = mN.x - dragStartNorm.value.x
|
||||
const dy = mN.y - dragStartNorm.value.y
|
||||
const nb = applyDrag(dragMode.value, boxAtStart.value, dx, dy)
|
||||
state.value.regions[activeIndex.value] = snapRegion(nb, dragMode.value)
|
||||
state.value.regions[activeIndex.value] = nb
|
||||
requestDraw()
|
||||
}
|
||||
|
||||
@@ -467,7 +375,7 @@ export function useBoundingBoxes(
|
||||
drawing.value = false
|
||||
canvasEl.value?.releasePointerCapture?.(e.pointerId)
|
||||
const b = state.value.regions[activeIndex.value]
|
||||
if (b && (b.w < 0.005 || b.h < 0.005)) {
|
||||
if (b && (b.w < 0.005 || b.h < 0.005) && dragMode.value === 'draw') {
|
||||
removeRegion(activeIndex.value)
|
||||
}
|
||||
syncState()
|
||||
@@ -602,7 +510,6 @@ export function useBoundingBoxes(
|
||||
function clearAll() {
|
||||
state.value.regions = []
|
||||
activeIndex.value = -1
|
||||
setLastIncoming([])
|
||||
syncState()
|
||||
}
|
||||
|
||||
@@ -623,23 +530,6 @@ export function useBoundingBoxes(
|
||||
watch(isNodeSelected, () => requestDraw())
|
||||
watch([widthValue, heightValue], () => syncState())
|
||||
|
||||
watch(
|
||||
litegraphNode,
|
||||
(node) => {
|
||||
const props = node?.properties as { bboxGrid?: unknown } | undefined
|
||||
if (props && typeof props.bboxGrid === 'boolean')
|
||||
grid.value = props.bboxGrid
|
||||
},
|
||||
{ immediate: true }
|
||||
)
|
||||
watch(grid, (enabled) => {
|
||||
const props = litegraphNode.value?.properties as
|
||||
| Record<string, unknown>
|
||||
| undefined
|
||||
if (props) props.bboxGrid = enabled
|
||||
requestDraw()
|
||||
})
|
||||
|
||||
const nodeOutputStore = useNodeOutputStore()
|
||||
function applyImageDimensions(naturalWidth: number, naturalHeight: number) {
|
||||
const node = litegraphNode.value
|
||||
@@ -690,63 +580,10 @@ export function useBoundingBoxes(
|
||||
}
|
||||
img.src = url
|
||||
}
|
||||
function lastIncomingWidget() {
|
||||
return litegraphNode.value?.widgets?.find((w) => w.name === 'last_incoming')
|
||||
}
|
||||
|
||||
function lastIncomingValue(): BoundingBox[] {
|
||||
const value = lastIncomingWidget()?.value
|
||||
return Array.isArray(value) ? (value as BoundingBox[]) : []
|
||||
}
|
||||
|
||||
function setLastIncoming(boxes: BoundingBox[]) {
|
||||
const widget = lastIncomingWidget()
|
||||
if (!widget) return
|
||||
const next = cloneDeep(boxes)
|
||||
widget.value = next
|
||||
widget.callback?.(next)
|
||||
}
|
||||
|
||||
function applyIncomingBoxes(apply = true) {
|
||||
if (drawing.value) return
|
||||
const node = litegraphNode.value
|
||||
if (!node) return
|
||||
const slot = node.findInputSlot('bboxes')
|
||||
if (slot < 0 || !node.isInputConnected(slot)) return
|
||||
const outputs = nodeOutputStore.getNodeOutputs(node) as
|
||||
| NodeOutputWith<{ input_bboxes?: BoundingBox[] }>
|
||||
| undefined
|
||||
const incoming = outputs?.input_bboxes
|
||||
if (!incoming?.length) return
|
||||
const applied = lastIncomingValue()
|
||||
if (isEqual(incoming, applied)) return
|
||||
if (!apply) {
|
||||
if (!applied.length && state.value.regions.length)
|
||||
setLastIncoming(incoming)
|
||||
return
|
||||
}
|
||||
state.value.regions = fromBoundingBoxes(
|
||||
incoming,
|
||||
widthValue.value,
|
||||
heightValue.value
|
||||
)
|
||||
activeIndex.value = state.value.regions.length ? 0 : -1
|
||||
setLastIncoming(incoming)
|
||||
syncState()
|
||||
}
|
||||
|
||||
watch(
|
||||
() => nodeOutputStore.nodeOutputs,
|
||||
() => {
|
||||
updateBgImage()
|
||||
applyIncomingBoxes()
|
||||
},
|
||||
{ deep: true }
|
||||
)
|
||||
watch(() => nodeOutputStore.nodeOutputs, updateBgImage, { deep: true })
|
||||
watch(() => nodeOutputStore.nodePreviewImages, updateBgImage, { deep: true })
|
||||
|
||||
updateBgImage()
|
||||
applyIncomingBoxes(false)
|
||||
void nextTick(() => requestDraw())
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -771,7 +608,6 @@ export function useBoundingBoxes(
|
||||
commitInlineEditor,
|
||||
setActiveType,
|
||||
clearAll,
|
||||
syncState,
|
||||
grid
|
||||
syncState
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { EffectScope } from 'vue'
|
||||
import { effectScope, ref, shallowRef } from 'vue'
|
||||
|
||||
@@ -13,12 +13,17 @@ afterEach(() => {
|
||||
function setup(initial: string[]) {
|
||||
const modelValue = ref(initial)
|
||||
const container = shallowRef(document.createElement('div'))
|
||||
const picker = shallowRef(document.createElement('input'))
|
||||
const scope = effectScope()
|
||||
scopes.push(scope)
|
||||
const api = scope.run(() => usePaletteSwatchRow({ modelValue, container }))!
|
||||
return { modelValue, container, ...api }
|
||||
const api = scope.run(() =>
|
||||
usePaletteSwatchRow({ modelValue, container, picker })
|
||||
)!
|
||||
return { modelValue, container, picker, ...api }
|
||||
}
|
||||
|
||||
const mouseEvent = () => ({ stopPropagation: vi.fn() }) as unknown as MouseEvent
|
||||
|
||||
describe('usePaletteSwatchRow', () => {
|
||||
it('appends a default color', () => {
|
||||
const { modelValue, addColor } = setup(['#000000'])
|
||||
@@ -32,17 +37,31 @@ describe('usePaletteSwatchRow', () => {
|
||||
expect(modelValue.value).toEqual(['#a', '#c'])
|
||||
})
|
||||
|
||||
it('updates the color at an index', () => {
|
||||
const { modelValue, updateAt } = setup(['#a', '#b'])
|
||||
updateAt(1, '#123456')
|
||||
it('seeds the picker input with the clicked color before opening it', () => {
|
||||
const { picker, openPicker } = setup(['#112233'])
|
||||
const click = vi.spyOn(picker.value!, 'click')
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#112233')
|
||||
expect(click).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('falls back to white when the slot is empty', () => {
|
||||
const { picker, openPicker } = setup([''])
|
||||
openPicker(0, mouseEvent())
|
||||
expect(picker.value!.value).toBe('#ffffff')
|
||||
})
|
||||
|
||||
it('writes the picked color back to the open slot', () => {
|
||||
const { modelValue, openPicker, onPickerInput } = setup(['#a', '#b'])
|
||||
openPicker(1, mouseEvent())
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a', '#123456'])
|
||||
})
|
||||
|
||||
it('ignores an update that does not change the color', () => {
|
||||
const { modelValue, updateAt } = setup(['#a'])
|
||||
const before = modelValue.value
|
||||
updateAt(0, '#a')
|
||||
expect(modelValue.value).toBe(before)
|
||||
it('ignores picker input when no slot is open', () => {
|
||||
const { modelValue, onPickerInput } = setup(['#a'])
|
||||
onPickerInput({ target: { value: '#123456' } } as unknown as Event)
|
||||
expect(modelValue.value).toEqual(['#a'])
|
||||
})
|
||||
|
||||
it('reorders via drag when the pointer crosses another swatch', () => {
|
||||
|
||||
@@ -5,16 +5,30 @@ import { ref } from 'vue'
|
||||
interface UsePaletteSwatchRowOptions {
|
||||
modelValue: Ref<string[]>
|
||||
container: Readonly<ShallowRef<HTMLDivElement | null>>
|
||||
picker: Readonly<ShallowRef<HTMLInputElement | null>>
|
||||
}
|
||||
|
||||
export function usePaletteSwatchRow({
|
||||
modelValue,
|
||||
container
|
||||
container,
|
||||
picker
|
||||
}: UsePaletteSwatchRowOptions) {
|
||||
function updateAt(i: number, value: string) {
|
||||
if (modelValue.value[i] === value) return
|
||||
const pickerIndex = ref<number | null>(null)
|
||||
|
||||
function openPicker(i: number, e: MouseEvent) {
|
||||
e.stopPropagation()
|
||||
pickerIndex.value = i
|
||||
const el = picker.value
|
||||
if (!el) return
|
||||
el.value = modelValue.value[i] || '#ffffff'
|
||||
el.click()
|
||||
}
|
||||
|
||||
function onPickerInput(e: Event) {
|
||||
const v = (e.target as HTMLInputElement).value
|
||||
if (pickerIndex.value === null) return
|
||||
const next = modelValue.value.slice()
|
||||
next[i] = value
|
||||
next[pickerIndex.value] = v
|
||||
modelValue.value = next
|
||||
}
|
||||
|
||||
@@ -91,7 +105,8 @@ export function usePaletteSwatchRow({
|
||||
})
|
||||
|
||||
return {
|
||||
updateAt,
|
||||
openPicker,
|
||||
onPickerInput,
|
||||
remove,
|
||||
addColor,
|
||||
onPointerDown
|
||||
|
||||
@@ -1,316 +0,0 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { SearchResult } from 'minisearch'
|
||||
|
||||
import {
|
||||
createTemplateSearchIndex,
|
||||
expandAbbreviation,
|
||||
expandQuery,
|
||||
rankByRelevanceThenUsage,
|
||||
searchTemplates,
|
||||
termFuzziness,
|
||||
tokenize
|
||||
} from '@/composables/templateSearchConfig'
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
const buildTemplate = (
|
||||
overrides: Partial<TemplateInfo> & { name: string }
|
||||
): TemplateInfo => ({
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
...overrides
|
||||
})
|
||||
|
||||
describe('tokenize', () => {
|
||||
it('splits identifiers on hyphen and underscore', () => {
|
||||
expect(tokenize('video_ltx2_3_t2v')).toEqual([
|
||||
'video_ltx2_3_t2v',
|
||||
'video',
|
||||
'ltx2',
|
||||
'3',
|
||||
't2v'
|
||||
])
|
||||
})
|
||||
|
||||
it('splits a trailing version off its name so "wan 2.7" matches "wan2.7"', () => {
|
||||
expect(tokenize('wan2.7')).toEqual(['wan2.7', 'wan', '2.7'])
|
||||
})
|
||||
|
||||
it('keeps a mid-digit abbreviation whole', () => {
|
||||
expect(tokenize('t2v')).toEqual(['t2v'])
|
||||
})
|
||||
|
||||
it('emits a unigram and bigram for each char of an unspaced CJK run', () => {
|
||||
expect(tokenize('图像放大')).toEqual([
|
||||
'图',
|
||||
'像',
|
||||
'放',
|
||||
'大',
|
||||
'图像',
|
||||
'像放',
|
||||
'放大'
|
||||
])
|
||||
})
|
||||
|
||||
it('grams katakana including the prolonged-sound mark', () => {
|
||||
expect(tokenize('データ')).toEqual(['デ', 'ー', 'タ', 'デー', 'ータ'])
|
||||
})
|
||||
|
||||
it('treats Korean as a spaced script, not an unspaced CJK run', () => {
|
||||
expect(tokenize('업스케일')).toEqual(['업스케일'])
|
||||
})
|
||||
|
||||
it('grams the CJK part of a word glued to latin, keeping the whole word', () => {
|
||||
expect(tokenize('flux图像')).toEqual(['图', '像', '图像', 'flux图像'])
|
||||
})
|
||||
|
||||
it('lowercases and drops empty tokens', () => {
|
||||
expect(tokenize(' Flux Kontext ')).toEqual(['flux', 'kontext'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('termFuzziness', () => {
|
||||
it('is exact for short terms (≤3 chars)', () => {
|
||||
expect(termFuzziness('t2v')).toBe(false)
|
||||
expect(termFuzziness('cn')).toBe(false)
|
||||
})
|
||||
|
||||
it('is exact for any term containing a digit so versions do not blur', () => {
|
||||
expect(termFuzziness('2.5')).toBe(false)
|
||||
expect(termFuzziness('flux2')).toBe(false)
|
||||
})
|
||||
|
||||
it('allows edits for longer alphabetic terms', () => {
|
||||
expect(termFuzziness('control')).not.toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandAbbreviation', () => {
|
||||
it('expands cross-modality shorthand', () => {
|
||||
expect(expandAbbreviation('t2i')).toBe('text image')
|
||||
expect(expandAbbreviation('i2v')).toBe('image video')
|
||||
expect(expandAbbreviation('txt2img')).toBe('text image')
|
||||
})
|
||||
|
||||
it('expands same-modality transforms to editing', () => {
|
||||
expect(expandAbbreviation('img2img')).toBe('image edit')
|
||||
expect(expandAbbreviation('v2v')).toBe('video edit')
|
||||
})
|
||||
|
||||
it('expands known acronyms', () => {
|
||||
expect(expandAbbreviation('cn')).toBe('controlnet')
|
||||
})
|
||||
|
||||
it('returns null for unknown tokens and unknown modalities', () => {
|
||||
expect(expandAbbreviation('flux')).toBeNull()
|
||||
expect(expandAbbreviation('x2y')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('expandQuery', () => {
|
||||
it('expands shorthand tokens within a multi-word query', () => {
|
||||
expect(expandQuery('wan i2v')).toBe('wan image video')
|
||||
})
|
||||
|
||||
it('returns null when nothing expands', () => {
|
||||
expect(expandQuery('flux upscale')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('searchTemplates', () => {
|
||||
const buildIndex = (templates: TemplateInfo[]) =>
|
||||
createTemplateSearchIndex(templates)
|
||||
|
||||
it('returns an empty array for a blank query without touching the index', () => {
|
||||
const index = buildIndex([buildTemplate({ name: 'a', title: 'Alpha' })])
|
||||
expect(searchTemplates(index, ' ')).toEqual([])
|
||||
})
|
||||
|
||||
it('matches a prefix ("vid" → "video")', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'video', title: 'Video Generator' }),
|
||||
buildTemplate({ name: 'audio', title: 'Audio Studio' })
|
||||
])
|
||||
expect(searchTemplates(index, 'vid')).toContain('video')
|
||||
expect(searchTemplates(index, 'vid')).not.toContain('audio')
|
||||
})
|
||||
|
||||
it('tolerates a typo in a longer term ("contorlnet")', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'cn',
|
||||
title: 'Union ControlNet',
|
||||
tags: ['ControlNet']
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'contorlnet')).toContain('cn')
|
||||
})
|
||||
|
||||
it('does not fuzzy-match a long word onto its shorter substring', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'real', title: 'SeedVR2 Image Upscale' }),
|
||||
buildTemplate({
|
||||
name: 'junk',
|
||||
title: 'Anime Text to Image',
|
||||
description: 'configure CFG scale and steps'
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'upscale')).toContain('real')
|
||||
expect(searchTemplates(index, 'upscale')).not.toContain('junk')
|
||||
})
|
||||
|
||||
it('matches a CJK term inside an unspaced CJK title', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'zh_upscale', title: '图像放大' }), // "image upscale"
|
||||
buildTemplate({ name: 'zh_video', title: '视频补帧' }) // "video interpolation"
|
||||
])
|
||||
// 放大 = "upscale"
|
||||
expect(searchTemplates(index, '放大')).toContain('zh_upscale')
|
||||
expect(searchTemplates(index, '放大')).not.toContain('zh_video')
|
||||
})
|
||||
|
||||
it('matches a single CJK character that ends an unspaced run', () => {
|
||||
const index = buildIndex([buildTemplate({ name: 'zh', title: '图像放大' })])
|
||||
// 大 is only the trailing half of the last bigram; the unigram reaches it.
|
||||
expect(searchTemplates(index, '大')).toContain('zh')
|
||||
})
|
||||
|
||||
it('requires all words to match (AND) before falling back to OR', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'both',
|
||||
title: 'Flux Upscale',
|
||||
models: ['Flux'],
|
||||
tags: ['Upscale']
|
||||
}),
|
||||
buildTemplate({ name: 'flux_only', title: 'Flux Text to Image' })
|
||||
])
|
||||
expect(searchTemplates(index, 'flux upscale')[0]).toBe('both')
|
||||
})
|
||||
|
||||
it('breaks a near-tie by higher usage', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'low', title: 'Alpha Upscale', usage: 1 }),
|
||||
buildTemplate({ name: 'high', title: 'Beta Upscale', usage: 5000 })
|
||||
])
|
||||
expect(searchTemplates(index, 'upscale')[0]).toBe('high')
|
||||
})
|
||||
|
||||
it('does not let usage override a clearly stronger text match', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'exact', title: 'Outpaint', usage: 1 }),
|
||||
buildTemplate({
|
||||
name: 'weak',
|
||||
title: 'Portrait',
|
||||
description: 'has an outpaint option somewhere',
|
||||
usage: 9000
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'outpaint')[0]).toBe('exact')
|
||||
})
|
||||
|
||||
it('deduplicates literal and expansion matches, keeping the literal first', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'literal', title: 'Wan T2V', tags: ['T2V'] }),
|
||||
buildTemplate({ name: 'expanded', title: 'Text to Video Studio' })
|
||||
])
|
||||
const results = searchTemplates(index, 't2v')
|
||||
expect(results[0]).toBe('literal')
|
||||
expect(new Set(results).size).toBe(results.length)
|
||||
})
|
||||
|
||||
it('indexes localized title/description over raw english', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'localized',
|
||||
title: 'raw',
|
||||
localizedTitle: 'aquarela',
|
||||
description: 'raw',
|
||||
localizedDescription: 'pintura'
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'aquarela')).toEqual(['localized'])
|
||||
expect(searchTemplates(index, 'pintura')).toEqual(['localized'])
|
||||
})
|
||||
|
||||
it('falls back to the name when a template has no title or description', () => {
|
||||
const index = buildIndex([buildTemplate({ name: 'flux_kontext_edit' })])
|
||||
expect(searchTemplates(index, 'kontext')).toEqual(['flux_kontext_edit'])
|
||||
})
|
||||
|
||||
it('ranks an editing template above text-to-image for "img2img"', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'text_to_image',
|
||||
title: 'Qwen Text to Image',
|
||||
tags: ['Text to Image']
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'image_edit',
|
||||
title: 'Qwen Image Edit',
|
||||
tags: ['Image Edit']
|
||||
})
|
||||
])
|
||||
expect(searchTemplates(index, 'img2img')[0]).toBe('image_edit')
|
||||
})
|
||||
|
||||
it('ranks a title match above a tag match above a description-only match', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({
|
||||
name: 'in_description',
|
||||
title: 'Something Else',
|
||||
description: 'mentions upscale in passing'
|
||||
}),
|
||||
buildTemplate({ name: 'in_tag', title: 'Something', tags: ['Upscale'] }),
|
||||
buildTemplate({ name: 'in_title', title: 'Upscale Studio' })
|
||||
])
|
||||
expect(searchTemplates(index, 'upscale')).toEqual([
|
||||
'in_title',
|
||||
'in_tag',
|
||||
'in_description'
|
||||
])
|
||||
})
|
||||
|
||||
it('ranks an exact title above a title with extra words', () => {
|
||||
const index = buildIndex([
|
||||
buildTemplate({ name: 'with_extra', title: 'ControlNet Guidance' }),
|
||||
buildTemplate({ name: 'exact', title: 'ControlNet' })
|
||||
])
|
||||
expect(searchTemplates(index, 'controlnet')[0]).toBe('exact')
|
||||
})
|
||||
})
|
||||
|
||||
describe('rankByRelevanceThenUsage', () => {
|
||||
const hit = (id: string, score: number, usage: number): SearchResult =>
|
||||
({ id, score, usage }) as unknown as SearchResult
|
||||
|
||||
// Scores 0.93/0.965/1.0 with usages 100/50/1 form an intransitive cycle under
|
||||
// a pairwise relative-band compare (A>B, B>C, but A<C), which makes Array.sort
|
||||
// input-order-dependent. Bucketing must give one stable order for any input.
|
||||
it('produces a stable order for an intransitive cluster', () => {
|
||||
const a = hit('a', 0.93, 100)
|
||||
const b = hit('b', 0.965, 50)
|
||||
const c = hit('c', 1.0, 1)
|
||||
|
||||
const order = (hits: SearchResult[]) =>
|
||||
rankByRelevanceThenUsage(hits).map((h) => h.id)
|
||||
|
||||
const expected = order([a, b, c])
|
||||
expect(order([c, b, a])).toEqual(expected)
|
||||
expect(order([b, a, c])).toEqual(expected)
|
||||
expect(order([c, a, b])).toEqual(expected)
|
||||
})
|
||||
|
||||
it('breaks ties within a band by usage but not across bands', () => {
|
||||
const strong = hit('strong', 1.0, 1)
|
||||
const nearStrong = hit('near', 0.98, 500)
|
||||
const weak = hit('weak', 0.5, 9000)
|
||||
|
||||
const ids = rankByRelevanceThenUsage([weak, strong, nearStrong]).map(
|
||||
(h) => h.id
|
||||
)
|
||||
// near (higher usage, same band as strong) leads; weak stays last on score.
|
||||
expect(ids).toEqual(['near', 'strong', 'weak'])
|
||||
})
|
||||
})
|
||||
@@ -1,204 +0,0 @@
|
||||
import MiniSearch from 'minisearch'
|
||||
import type { SearchResult } from 'minisearch'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
|
||||
// MiniSearch serializes the index but not the search options, so the tokenizer
|
||||
// and field list live here and are used at both index and query time.
|
||||
|
||||
const SEARCH_FIELDS = [
|
||||
'title',
|
||||
'description',
|
||||
'tags',
|
||||
'models',
|
||||
'name'
|
||||
] as const
|
||||
|
||||
// Usage only reorders hits within this fraction of the top score, so popularity
|
||||
// never overrides a clearly-better text match. 5% tuned empirically.
|
||||
const USAGE_TIEBREAK_BAND = 0.05
|
||||
|
||||
// Script-matched so spaced neighbors like Korean fall to the word tokenizer.
|
||||
const CJK = /[\p{scx=Han}\p{scx=Hiragana}\p{scx=Katakana}]/u
|
||||
const CJK_RUN = new RegExp(`${CJK.source}+`, 'gu')
|
||||
|
||||
// Unigrams + bigrams so any substring of an unspaced run lands on a token.
|
||||
function cjkGrams(word: string): string[] {
|
||||
const grams: string[] = []
|
||||
for (const run of word.match(CJK_RUN) ?? []) {
|
||||
const characters = run.split('')
|
||||
grams.push(...characters)
|
||||
for (let i = 1; i < characters.length; i++) {
|
||||
grams.push(characters[i - 1] + characters[i])
|
||||
}
|
||||
}
|
||||
return grams
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits sub-parts so a term matches however it's typed: `-`/`_` splits, a
|
||||
* trailing version (`wan2.7` → `wan`, `2.7`), and CJK character grams.
|
||||
*/
|
||||
export function tokenize(text: string): string[] {
|
||||
const tokens = new Set<string>()
|
||||
for (const word of text.toLowerCase().split(/\s+/).filter(Boolean)) {
|
||||
for (const gram of cjkGrams(word)) tokens.add(gram)
|
||||
// A pure-CJK run has no whole-word token — its grams already cover it.
|
||||
if (CJK.test(word) && !/[a-z0-9]/.test(word)) continue
|
||||
tokens.add(word)
|
||||
for (const part of word.split(/[-_]/)) {
|
||||
if (part) tokens.add(part)
|
||||
}
|
||||
const version = word.match(/^([a-z][a-z.]*?)(\d+(?:\.\d+)*)$/)
|
||||
if (version) {
|
||||
tokens.add(version[1].replace(/\.$/, ''))
|
||||
tokens.add(version[2])
|
||||
}
|
||||
}
|
||||
return [...tokens]
|
||||
}
|
||||
|
||||
// Exact for ≤3-char and digit-bearing terms; otherwise 20% of length, so a typo
|
||||
// is forgiven but `upscale` can't fuzzy-match the shorter `scale`.
|
||||
export function termFuzziness(term: string): number | false {
|
||||
return term.length <= 3 || /\d/.test(term) ? false : 0.2
|
||||
}
|
||||
|
||||
function searchOptions(combineWith: 'AND' | 'OR' = 'AND') {
|
||||
// Description demoted below default so an incidental prose mention never
|
||||
// outranks a real title/model match.
|
||||
return {
|
||||
boost: { title: 3, models: 2, tags: 2, description: 0.5 },
|
||||
prefix: true,
|
||||
fuzzy: termFuzziness,
|
||||
combineWith,
|
||||
tokenize
|
||||
}
|
||||
}
|
||||
|
||||
// `{X}2{Y}` shorthand expanded by structure (t2i, txt2img, …) rather than one
|
||||
// entry per spelling.
|
||||
const MODALITY_STEMS: Record<string, string> = {
|
||||
t: 'text',
|
||||
txt: 'text',
|
||||
text: 'text',
|
||||
i: 'image',
|
||||
img: 'image',
|
||||
image: 'image',
|
||||
v: 'video',
|
||||
vid: 'video',
|
||||
video: 'video',
|
||||
a: 'audio',
|
||||
s: 'audio',
|
||||
m: 'music'
|
||||
}
|
||||
|
||||
const SAME_MODALITY_EDIT: Record<string, string> = {
|
||||
image: 'image edit',
|
||||
video: 'video edit',
|
||||
audio: 'audio edit'
|
||||
}
|
||||
|
||||
const ACRONYMS: Record<string, string> = {
|
||||
cn: 'controlnet'
|
||||
}
|
||||
|
||||
export function expandAbbreviation(token: string): string | null {
|
||||
const lower = token.trim().toLowerCase()
|
||||
if (ACRONYMS[lower]) return ACRONYMS[lower]
|
||||
|
||||
const match = lower.match(/^([a-z]+)2([a-z]+)$/)
|
||||
if (!match) return null
|
||||
const left = MODALITY_STEMS[match[1]]
|
||||
const right = MODALITY_STEMS[match[2]]
|
||||
if (!left || !right) return null
|
||||
if (left === right) return SAME_MODALITY_EDIT[left] ?? left
|
||||
return `${left} ${right}`
|
||||
}
|
||||
|
||||
/** Expands shorthand tokens (`wan i2v` → `wan image video`); null if none expand. */
|
||||
export function expandQuery(query: string): string | null {
|
||||
let changed = false
|
||||
const out = query
|
||||
.split(/\s+/)
|
||||
.filter(Boolean)
|
||||
.map((token) => {
|
||||
const expansion = expandAbbreviation(token.toLowerCase())
|
||||
if (expansion) changed = true
|
||||
return expansion ?? token
|
||||
})
|
||||
.join(' ')
|
||||
return changed ? out : null
|
||||
}
|
||||
|
||||
export function createTemplateSearchIndex(
|
||||
templates: TemplateInfo[]
|
||||
): MiniSearch<TemplateInfo> {
|
||||
const index = new MiniSearch<TemplateInfo>({
|
||||
idField: 'name',
|
||||
fields: [...SEARCH_FIELDS],
|
||||
// Returned on each hit so the tiebreak can read usage without a second lookup.
|
||||
storeFields: ['usage'],
|
||||
// Index the localized strings the card actually shows, so a match explains
|
||||
// a visible result.
|
||||
extractField: (template, field) => {
|
||||
if (field === 'title') return template.localizedTitle ?? template.title
|
||||
if (field === 'description') {
|
||||
return template.localizedDescription ?? template.description ?? ''
|
||||
}
|
||||
const value = template[field as keyof TemplateInfo]
|
||||
return Array.isArray(value) ? value.join(' ') : ((value as string) ?? '')
|
||||
},
|
||||
tokenize,
|
||||
searchOptions: searchOptions('AND')
|
||||
})
|
||||
index.addAll(templates)
|
||||
return index
|
||||
}
|
||||
|
||||
// Rank by relevance, with usage breaking ties inside a score band. Scores are
|
||||
// bucketed so the ordering is a stable total order (a pairwise relative-band
|
||||
// compare is intransitive). log1p dampens heavy-tailed usage.
|
||||
export function rankByRelevanceThenUsage(hits: SearchResult[]): SearchResult[] {
|
||||
const bandSize =
|
||||
hits.reduce((max, hit) => Math.max(max, hit.score), 0) * USAGE_TIEBREAK_BAND
|
||||
const bucket = (score: number) =>
|
||||
bandSize > 0 ? Math.round(score / bandSize) : 0
|
||||
return [...hits].sort((a, b) => {
|
||||
if (bucket(a.score) !== bucket(b.score)) return b.score - a.score
|
||||
return Math.log1p(Number(b.usage ?? 0)) - Math.log1p(Number(a.usage ?? 0))
|
||||
})
|
||||
}
|
||||
|
||||
/** Ordered template names for a query: literal matches first, then dedup'd expansion matches. */
|
||||
export function searchTemplates(
|
||||
index: MiniSearch<TemplateInfo>,
|
||||
query: string
|
||||
): string[] {
|
||||
const trimmed = query.trim()
|
||||
if (!trimmed) return []
|
||||
|
||||
const andThenOr = (q: string): SearchResult[] => {
|
||||
const and = index.search(q, searchOptions('AND'))
|
||||
const hits = and.length > 0 ? and : index.search(q, searchOptions('OR'))
|
||||
return rankByRelevanceThenUsage(hits)
|
||||
}
|
||||
|
||||
const ordered: string[] = []
|
||||
const seen = new Set<string>()
|
||||
const collect = (hits: SearchResult[]) => {
|
||||
for (const hit of hits) {
|
||||
const id = String(hit.id)
|
||||
if (!seen.has(id)) {
|
||||
seen.add(id)
|
||||
ordered.push(id)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect(andThenOr(trimmed))
|
||||
const expanded = expandQuery(trimmed)
|
||||
if (expanded) collect(andThenOr(expanded))
|
||||
|
||||
return ordered
|
||||
}
|
||||
@@ -90,7 +90,9 @@ export function useExternalLink() {
|
||||
githubFrontend: 'https://github.com/Comfy-Org/ComfyUI_frontend',
|
||||
githubElectron: 'https://github.com/Comfy-Org/electron',
|
||||
forum: 'https://forum.comfy.org/',
|
||||
comfyOrg: 'https://www.comfy.org/'
|
||||
comfyOrg: 'https://www.comfy.org/',
|
||||
teamPlanRequests:
|
||||
'https://comfy-org.portal.usepylon.com/forms/team-plan-requests'
|
||||
}
|
||||
|
||||
/** Common doc paths for use with buildDocsUrl */
|
||||
|
||||
@@ -77,14 +77,6 @@ vi.mock('pinia', async (importOriginal) => {
|
||||
}
|
||||
})
|
||||
|
||||
const { settingGetMock } = vi.hoisted(() => ({
|
||||
settingGetMock: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/platform/settings/settingStore', () => ({
|
||||
useSettingStore: () => ({ get: settingGetMock })
|
||||
}))
|
||||
|
||||
vi.mock('@/renderer/core/canvas/canvasStore', () => ({
|
||||
useCanvasStore: vi.fn()
|
||||
}))
|
||||
@@ -103,9 +95,6 @@ describe('useLoad3d', () => {
|
||||
vi.clearAllMocks()
|
||||
nodeToLoad3dMap.clear()
|
||||
vi.mocked(getActivePinia).mockReturnValue(null as unknown as Pinia)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '282828' : undefined
|
||||
)
|
||||
|
||||
mockNode = createMockLGraphNode({
|
||||
properties: {
|
||||
@@ -214,7 +203,9 @@ describe('useLoad3d', () => {
|
||||
getModelInfo: vi.fn().mockReturnValue(null),
|
||||
captureThumbnail: vi.fn().mockResolvedValue('data:image/png;base64,test'),
|
||||
setAnimationTime: vi.fn(),
|
||||
domElement: mockCanvas
|
||||
renderer: {
|
||||
domElement: mockCanvas
|
||||
} as Partial<Load3d['renderer']> as Load3d['renderer']
|
||||
}
|
||||
|
||||
vi.mocked(Load3d).mockImplementation(function (this: Load3d) {
|
||||
@@ -301,7 +292,7 @@ describe('useLoad3d', () => {
|
||||
mockNode.flags.collapsed = true
|
||||
mockNode.onDrawBackground?.({} as CanvasRenderingContext2D)
|
||||
|
||||
expect(mockLoad3d.domElement!.hidden).toBe(true)
|
||||
expect(mockLoad3d.renderer!.domElement.hidden).toBe(true)
|
||||
})
|
||||
|
||||
it('should initialize without loading model (model loading is handled by Load3DConfiguration)', async () => {
|
||||
@@ -365,20 +356,6 @@ describe('useLoad3d', () => {
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should set preview mode for save-viewer nodes despite width/height widgets', async () => {
|
||||
Object.defineProperty(mockNode, 'constructor', {
|
||||
value: { comfyClass: 'Save3DAdvanced' },
|
||||
configurable: true
|
||||
})
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
const containerRef = document.createElement('div')
|
||||
|
||||
await composable.initializeLoad3d(containerRef)
|
||||
|
||||
expect(composable.isPreview.value).toBe(true)
|
||||
})
|
||||
|
||||
it('should handle initialization errors', async () => {
|
||||
vi.mocked(createLoad3d).mockImplementationOnce(() => {
|
||||
throw new Error('Load3d creation failed')
|
||||
@@ -406,37 +383,7 @@ describe('useLoad3d', () => {
|
||||
const nodeRef = shallowRef<LGraphNode | null>(mockNode)
|
||||
const composable = useLoad3d(nodeRef)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#282828')
|
||||
})
|
||||
|
||||
it('defaults background color from the Comfy.Load3D.BackgroundColor setting', () => {
|
||||
vi.mocked(getActivePinia).mockReturnValue({} as unknown as Pinia)
|
||||
vi.mocked(useCanvasStore).mockReturnValue(
|
||||
reactive({ appScalePercentage: 100 }) as unknown as ReturnType<
|
||||
typeof useCanvasStore
|
||||
>
|
||||
)
|
||||
settingGetMock.mockImplementation((key: string) =>
|
||||
key === 'Comfy.Load3D.BackgroundColor' ? '123456' : undefined
|
||||
)
|
||||
|
||||
const composable = useLoad3d(mockNode)
|
||||
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#123456')
|
||||
})
|
||||
|
||||
it('attaches event listeners before running queued ready callbacks', async () => {
|
||||
const composable = useLoad3d(mockNode)
|
||||
let listenersAttachedWhenCallbackRan = false
|
||||
|
||||
composable.waitForLoad3d(() => {
|
||||
listenersAttachedWhenCallbackRan =
|
||||
vi.mocked(mockLoad3d.addEventListener!).mock.calls.length > 0
|
||||
})
|
||||
|
||||
await composable.initializeLoad3d(document.createElement('div'))
|
||||
|
||||
expect(listenersAttachedWhenCallbackRan).toBe(true)
|
||||
expect(composable.sceneConfig.value.backgroundColor).toBe('#000000')
|
||||
})
|
||||
|
||||
it('passes getZoomScale callback to createLoad3d', async () => {
|
||||
|
||||
@@ -8,7 +8,6 @@ import { useChainCallback } from '@/composables/functional/useChainCallback'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import {
|
||||
isAssetPreviewSupported,
|
||||
persistThumbnail
|
||||
@@ -119,9 +118,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
const sceneConfig = ref<SceneConfig>({
|
||||
showGrid: true,
|
||||
backgroundColor: getActivePinia()
|
||||
? '#' + useSettingStore().get('Comfy.Load3D.BackgroundColor')
|
||||
: '#282828',
|
||||
backgroundColor: '#000000',
|
||||
backgroundImage: '',
|
||||
backgroundRenderMode: 'tiled'
|
||||
})
|
||||
@@ -180,7 +177,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const canExport = ref(true)
|
||||
const materialModes = ref<readonly MaterialMode[]>([
|
||||
'original',
|
||||
'clay',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
@@ -196,7 +192,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
|
||||
if (
|
||||
isLoad3dResultViewerNode(node.constructor.comfyClass ?? '') ||
|
||||
node.constructor.comfyClass?.startsWith('Preview') ||
|
||||
!(widthWidget && heightWidget)
|
||||
) {
|
||||
@@ -244,7 +239,7 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
node.onDrawBackground,
|
||||
function (this: LGraphNode) {
|
||||
if (load3d) {
|
||||
load3d.domElement.hidden = this.flags.collapsed ?? false
|
||||
load3d.renderer.domElement.hidden = this.flags.collapsed ?? false
|
||||
}
|
||||
}
|
||||
)
|
||||
@@ -253,8 +248,6 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
|
||||
nodeToLoad3dMap.set(node, load3d)
|
||||
|
||||
handleEvents('add')
|
||||
|
||||
const callbacks = pendingCallbacks.get(node)
|
||||
|
||||
if (callbacks && load3d) {
|
||||
@@ -270,6 +263,8 @@ export const useLoad3d = (nodeOrRef: MaybeRef<LGraphNode | null>) => {
|
||||
if (load3d) invokeReadyCallback(callback, load3d)
|
||||
})
|
||||
}
|
||||
|
||||
handleEvents('add')
|
||||
} catch (error) {
|
||||
console.error('Error initializing Load3d:', error)
|
||||
useToastStore().addAlert(
|
||||
|
||||
@@ -4,7 +4,7 @@ import QuickLRU from '@alloc/quick-lru'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3dUtils from '@/extensions/core/load3d/Load3dUtils'
|
||||
import { createLoad3d } from '@/extensions/core/load3d/createLoad3d'
|
||||
import { isLoad3dResultViewerNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import { isLoad3dPreviewNode } from '@/extensions/core/load3d/nodeTypes'
|
||||
import type {
|
||||
AnimationItem,
|
||||
BackgroundRenderModeType,
|
||||
@@ -371,7 +371,7 @@ export const useLoad3dViewer = (node?: LGraphNode) => {
|
||||
| LightConfig
|
||||
| undefined
|
||||
|
||||
isPreview.value = isLoad3dResultViewerNode(node.type ?? '')
|
||||
isPreview.value = isLoad3dPreviewNode(node.type ?? '')
|
||||
|
||||
if (sceneConfig) {
|
||||
backgroundColor.value =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { nextTick, ref } from 'vue'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
|
||||
import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { TemplateIncludeOnDistributionEnum } from '@/platform/workflow/templates/types/template'
|
||||
@@ -23,11 +24,11 @@ const defaultSettingStore = {
|
||||
}
|
||||
|
||||
const defaultRankingStore = {
|
||||
computeDefaultScore: vi.fn(
|
||||
(_date?: string, _rank?: number, usage: number = 0) => usage
|
||||
),
|
||||
computeDefaultScore: vi.fn(() => 0),
|
||||
computePopularScore: vi.fn(() => 0),
|
||||
getUsageScore: vi.fn(() => 0),
|
||||
computeFreshness: vi.fn(() => 0.5),
|
||||
largestUsageScore: 0
|
||||
isLoaded: { value: false }
|
||||
}
|
||||
|
||||
const mockSystemStatsStore = {
|
||||
@@ -50,10 +51,9 @@ vi.mock('@/stores/systemStatsStore', () => ({
|
||||
useSystemStatsStore: vi.fn(() => mockSystemStatsStore)
|
||||
}))
|
||||
|
||||
const trackTemplateFilterChanged = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/platform/telemetry', () => ({
|
||||
useTelemetry: vi.fn(() => ({
|
||||
trackTemplateFilterChanged,
|
||||
trackTemplateFilterChanged: vi.fn(),
|
||||
trackSearchQuery: vi.fn()
|
||||
}))
|
||||
}))
|
||||
@@ -62,12 +62,20 @@ vi.mock('@/platform/telemetry/searchQuery/useSearchQueryTracking', () => ({
|
||||
useSearchQueryTracking: vi.fn()
|
||||
}))
|
||||
|
||||
const mockGetFuseOptions = vi.hoisted(() => vi.fn())
|
||||
vi.mock('@/scripts/api', () => ({
|
||||
api: {
|
||||
getFuseOptions: mockGetFuseOptions
|
||||
}
|
||||
}))
|
||||
|
||||
describe('useTemplateFiltering', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('__DISTRIBUTION__', 'localhost')
|
||||
mockSystemStatsStore.systemStats.system.os = 'linux'
|
||||
mockGetFuseOptions.mockResolvedValue(null)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
@@ -240,423 +248,117 @@ describe('useTemplateFiltering', () => {
|
||||
])
|
||||
})
|
||||
|
||||
const usageRankedTemplates = () =>
|
||||
ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'low',
|
||||
title: 'Low',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
usage: 10
|
||||
},
|
||||
{
|
||||
name: 'high',
|
||||
title: 'High',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
usage: 900
|
||||
}
|
||||
])
|
||||
|
||||
it('ranks "recommended" via computeDefaultScore', async () => {
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(
|
||||
usageRankedTemplates()
|
||||
)
|
||||
|
||||
sortBy.value = 'recommended'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'high',
|
||||
'low'
|
||||
])
|
||||
expect(defaultRankingStore.computeDefaultScore).toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('ranks "popular" by raw usage without the recommended score', async () => {
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(
|
||||
usageRankedTemplates()
|
||||
)
|
||||
|
||||
sortBy.value = 'popular'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'high',
|
||||
'low'
|
||||
])
|
||||
expect(defaultRankingStore.computeDefaultScore).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('filters to ComfyUI templates via the Runs On filter', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'open',
|
||||
title: 'Open',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
openSource: true
|
||||
},
|
||||
{
|
||||
name: 'partner',
|
||||
title: 'Partner',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
openSource: false
|
||||
}
|
||||
])
|
||||
|
||||
const { selectedRunsOn, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
selectedRunsOn.value = ['ComfyUI']
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'open'
|
||||
])
|
||||
})
|
||||
|
||||
it('sorts alphabetically by the localized title shown on the card', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'z-raw',
|
||||
title: 'Apple', // raw title would sort first
|
||||
localizedTitle: 'Zebra', // but the card shows this
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'a-raw',
|
||||
title: 'Zulu',
|
||||
localizedTitle: 'Ant',
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
sortBy.value = 'alphabetical'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'a-raw', // Ant
|
||||
'z-raw' // Zebra
|
||||
])
|
||||
})
|
||||
|
||||
it('A-Z trims whitespace, groups numbers after letters, and orders them naturally', async () => {
|
||||
const make = (name: string, title: string): TemplateInfo => ({
|
||||
name,
|
||||
title,
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
})
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
make('ten', '1.10 Model'),
|
||||
make('two', '1.2 Model'),
|
||||
make('spaced', ' Apple'), // leading space must not jump to the top
|
||||
make('zebra', 'Zebra')
|
||||
])
|
||||
|
||||
const { sortBy, filteredTemplates } = useTemplateFiltering(templates)
|
||||
sortBy.value = 'alphabetical'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.map((template) => template.name)).toEqual([
|
||||
'spaced', // " Apple" trimmed → sorts as a letter, first
|
||||
'zebra',
|
||||
'two', // numbers grouped after letters; 1.2 before 1.10 (numeric)
|
||||
'ten'
|
||||
])
|
||||
})
|
||||
|
||||
describe('Search relevance (MiniSearch)', () => {
|
||||
const names = (templates: { name: string }[]) =>
|
||||
templates.map((template) => template.name)
|
||||
|
||||
const buildTemplate = (
|
||||
overrides: Partial<TemplateInfo> & { name: string }
|
||||
): TemplateInfo => ({
|
||||
description: '',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png',
|
||||
...overrides
|
||||
})
|
||||
|
||||
async function searchFor(templates: TemplateInfo[], query: string) {
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.searchQuery.value = query
|
||||
await nextTick()
|
||||
return composable
|
||||
}
|
||||
|
||||
it('matches "img2img" via abbreviation expansion', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'z_image_t2i',
|
||||
title: 'Z-Image: Text to Image',
|
||||
tags: ['Text to Image']
|
||||
}),
|
||||
buildTemplate({ name: 'video_gen', title: 'LTX Text to Video' })
|
||||
]
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'img2img')
|
||||
|
||||
expect(names(filteredTemplates.value)).toContain('z_image_t2i')
|
||||
expect(names(filteredTemplates.value)).not.toContain('video_gen')
|
||||
})
|
||||
|
||||
it('matches multi-word cross-field queries like "flux upscale"', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'flux_upscale',
|
||||
title: 'Flux.1 Creative Upscale',
|
||||
models: ['Flux.1'],
|
||||
tags: ['Image Upscale']
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'flux_txt2img',
|
||||
title: 'Flux.1 Text to Image',
|
||||
models: ['Flux.1']
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'seedvr_upscale',
|
||||
title: 'SeedVR2 Upscale',
|
||||
tags: ['Image Upscale']
|
||||
})
|
||||
]
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'flux upscale')
|
||||
|
||||
expect(names(filteredTemplates.value)[0]).toBe('flux_upscale')
|
||||
})
|
||||
|
||||
it('breaks near-tied relevance by usage, dampened so it cannot override a better match', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'low_usage_upscale',
|
||||
title: 'Alpha Image Upscale',
|
||||
tags: ['Image Upscale'],
|
||||
usage: 5
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'high_usage_upscale',
|
||||
title: 'Beta Image Upscale',
|
||||
tags: ['Image Upscale'],
|
||||
usage: 5000
|
||||
})
|
||||
]
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'upscale')
|
||||
|
||||
// Near-identical text scores → the far more used template ranks first.
|
||||
expect(names(filteredTemplates.value)[0]).toBe('high_usage_upscale')
|
||||
})
|
||||
|
||||
it('keeps relevance order even when a usage sort is persisted', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'exact_match',
|
||||
title: 'Outpaint Studio',
|
||||
tags: ['Outpaint'],
|
||||
usage: 1
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'popular_weak_match',
|
||||
title: 'Portrait Generator',
|
||||
description: 'supports outpaint as a minor feature',
|
||||
usage: 9000
|
||||
})
|
||||
]
|
||||
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.sortBy.value = 'popular'
|
||||
composable.searchQuery.value = 'outpaint'
|
||||
await nextTick()
|
||||
|
||||
// Search defaults to relevance regardless of the persisted browse sort,
|
||||
// so the exact title match wins over the high-usage weak match.
|
||||
expect(composable.sortSelection.value).toBe('relevance')
|
||||
expect(names(composable.filteredTemplates.value)[0]).toBe('exact_match')
|
||||
})
|
||||
|
||||
it('lets the user override relevance with another sort while searching', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'exact_low_usage',
|
||||
title: 'Outpaint Studio',
|
||||
usage: 1
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'weak_high_usage',
|
||||
title: 'Portrait',
|
||||
description: 'outpaint mentioned once',
|
||||
usage: 9000
|
||||
})
|
||||
]
|
||||
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.searchQuery.value = 'outpaint'
|
||||
await nextTick()
|
||||
expect(composable.sortSelection.value).toBe('relevance')
|
||||
expect(names(composable.filteredTemplates.value)[0]).toBe(
|
||||
'exact_low_usage'
|
||||
)
|
||||
|
||||
composable.sortSelection.value = 'popular'
|
||||
await nextTick()
|
||||
expect(composable.sortSelection.value).toBe('popular')
|
||||
expect(names(composable.filteredTemplates.value)[0]).toBe(
|
||||
'weak_high_usage'
|
||||
)
|
||||
})
|
||||
|
||||
it('restores the browse sort when the search is cleared and keeps relevance ephemeral', async () => {
|
||||
const composable = useTemplateFiltering(
|
||||
ref([buildTemplate({ name: 'only', title: 'Only' })])
|
||||
)
|
||||
composable.sortBy.value = 'popular'
|
||||
|
||||
composable.searchQuery.value = 'only'
|
||||
await nextTick()
|
||||
expect(composable.sortSelection.value).toBe('relevance')
|
||||
|
||||
composable.searchQuery.value = ''
|
||||
await nextTick()
|
||||
// Browse sort is untouched by the search; relevance is never persisted.
|
||||
expect(composable.sortSelection.value).toBe('popular')
|
||||
expect(composable.sortBy.value).toBe('popular')
|
||||
})
|
||||
|
||||
it('keeps a browse sort chosen mid-search ephemeral', async () => {
|
||||
const composable = useTemplateFiltering(
|
||||
ref([buildTemplate({ name: 'only', title: 'Only' })])
|
||||
)
|
||||
composable.sortBy.value = 'newest'
|
||||
|
||||
composable.searchQuery.value = 'only'
|
||||
await nextTick()
|
||||
// Simulates the nav coordinator picking Popular during a search.
|
||||
composable.sortSelection.value = 'popular'
|
||||
await nextTick()
|
||||
expect(composable.sortBy.value).toBe('newest') // persisted sort untouched
|
||||
|
||||
composable.searchQuery.value = ''
|
||||
await nextTick()
|
||||
expect(composable.sortSelection.value).toBe('newest')
|
||||
})
|
||||
|
||||
it('returns no results for a query that matches nothing', async () => {
|
||||
const templates = [
|
||||
buildTemplate({ name: 'flux_image', title: 'Flux Image' })
|
||||
]
|
||||
|
||||
const { filteredTemplates, filteredCount } = await searchFor(
|
||||
templates,
|
||||
'zzzznomatch'
|
||||
)
|
||||
|
||||
expect(filteredTemplates.value).toEqual([])
|
||||
expect(filteredCount.value).toBe(0)
|
||||
})
|
||||
|
||||
it('matches the localized title the card displays', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'localized_only',
|
||||
title: 'raw english',
|
||||
localizedTitle: 'aquarela'
|
||||
})
|
||||
]
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'aquarela')
|
||||
|
||||
expect(names(filteredTemplates.value)).toEqual(['localized_only'])
|
||||
})
|
||||
|
||||
it('reports the visible sort to telemetry, not the persisted browse sort', async () => {
|
||||
vi.useFakeTimers()
|
||||
try {
|
||||
const composable = useTemplateFiltering(
|
||||
ref([buildTemplate({ name: 'only', title: 'Only' })])
|
||||
)
|
||||
composable.sortBy.value = 'popular'
|
||||
composable.searchQuery.value = 'only'
|
||||
await nextTick()
|
||||
await vi.runOnlyPendingTimersAsync()
|
||||
|
||||
// Searching shows relevance, so telemetry must report relevance, not popular.
|
||||
expect(trackTemplateFilterChanged).toHaveBeenLastCalledWith(
|
||||
expect.objectContaining({ sort_by: 'relevance' })
|
||||
)
|
||||
} finally {
|
||||
vi.useRealTimers()
|
||||
}
|
||||
})
|
||||
|
||||
it('preserves relevance order after a model filter narrows the results', async () => {
|
||||
const templates = [
|
||||
buildTemplate({
|
||||
name: 'strong',
|
||||
title: 'Flux Upscale Pro',
|
||||
models: ['Flux'],
|
||||
tags: ['Upscale']
|
||||
}),
|
||||
buildTemplate({
|
||||
name: 'weak',
|
||||
title: 'Flux Portrait',
|
||||
models: ['Flux'],
|
||||
description: 'mentions upscale once'
|
||||
})
|
||||
]
|
||||
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.searchQuery.value = 'upscale'
|
||||
composable.selectedModels.value = ['Flux']
|
||||
await nextTick()
|
||||
|
||||
// The filter keeps the search order; the stronger match stays first.
|
||||
expect(names(composable.filteredTemplates.value)).toEqual([
|
||||
'strong',
|
||||
'weak'
|
||||
describe('loadFuseOptions', () => {
|
||||
it('updates fuseOptions when getFuseOptions returns valid options', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'test-template',
|
||||
description: 'Test template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const customFuseOptions: IFuseOptions<TemplateInfo> = {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.5 },
|
||||
{ name: 'description', weight: 0.5 }
|
||||
],
|
||||
threshold: 0.4,
|
||||
includeScore: true
|
||||
}
|
||||
|
||||
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
|
||||
|
||||
const { loadFuseOptions, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
await loadFuseOptions()
|
||||
|
||||
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
|
||||
expect(filteredTemplates.value).toBeDefined()
|
||||
})
|
||||
|
||||
it('narrows to the query then restores the full set when cleared', async () => {
|
||||
const templates = [
|
||||
buildTemplate({ name: 'alpha_one', title: 'Alpha' }),
|
||||
buildTemplate({ name: 'beta_two', title: 'Beta' })
|
||||
]
|
||||
it('does not update fuseOptions when getFuseOptions returns null', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'test-template',
|
||||
description: 'Test template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const composable = useTemplateFiltering(ref(templates))
|
||||
composable.searchQuery.value = 'alpha'
|
||||
await nextTick()
|
||||
expect(names(composable.filteredTemplates.value)).toEqual(['alpha_one'])
|
||||
mockGetFuseOptions.mockResolvedValueOnce(null)
|
||||
|
||||
composable.searchQuery.value = ''
|
||||
await nextTick()
|
||||
expect(names(composable.filteredTemplates.value)).toHaveLength(2)
|
||||
const { loadFuseOptions, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
const initialResults = filteredTemplates.value
|
||||
|
||||
await loadFuseOptions()
|
||||
|
||||
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
|
||||
expect(filteredTemplates.value).toEqual(initialResults)
|
||||
})
|
||||
|
||||
it('records a non-negative largest usage score after an empty-result search', async () => {
|
||||
const templates = [
|
||||
buildTemplate({ name: 'gamma', title: 'Gamma', usage: 3 }),
|
||||
buildTemplate({ name: 'delta', title: 'Delta', usage: 7 })
|
||||
]
|
||||
it('handles errors when getFuseOptions fails', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'test-template',
|
||||
description: 'Test template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { filteredTemplates } = await searchFor(templates, 'zzzznomatch')
|
||||
expect(filteredTemplates.value).toEqual([])
|
||||
mockGetFuseOptions.mockRejectedValueOnce(new Error('Network error'))
|
||||
|
||||
// Empty results must yield 0, not -Infinity, which would corrupt
|
||||
// usage-normalized ranking scores.
|
||||
expect(defaultRankingStore.largestUsageScore).toBe(0)
|
||||
const { loadFuseOptions, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
const initialResults = filteredTemplates.value
|
||||
|
||||
await expect(loadFuseOptions()).rejects.toThrow('Network error')
|
||||
expect(filteredTemplates.value).toEqual(initialResults)
|
||||
})
|
||||
|
||||
it('recreates Fuse instance when fuseOptions change', async () => {
|
||||
const templates = ref<TemplateInfo[]>([
|
||||
{
|
||||
name: 'searchable-template',
|
||||
description: 'This is a searchable template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
},
|
||||
{
|
||||
name: 'another-template',
|
||||
description: 'Another template',
|
||||
mediaType: 'image',
|
||||
mediaSubtype: 'png'
|
||||
}
|
||||
])
|
||||
|
||||
const { loadFuseOptions, searchQuery, filteredTemplates } =
|
||||
useTemplateFiltering(templates)
|
||||
|
||||
const customFuseOptions = {
|
||||
keys: [{ name: 'name', weight: 1.0 }],
|
||||
threshold: 0.2,
|
||||
includeScore: true,
|
||||
includeMatches: true
|
||||
}
|
||||
|
||||
mockGetFuseOptions.mockResolvedValueOnce(customFuseOptions)
|
||||
|
||||
await loadFuseOptions()
|
||||
await nextTick()
|
||||
|
||||
searchQuery.value = 'searchable'
|
||||
await nextTick()
|
||||
|
||||
expect(filteredTemplates.value.length).toBeGreaterThan(0)
|
||||
expect(mockGetFuseOptions).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
})
|
||||
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { watchDebounced } from '@vueuse/core'
|
||||
import { refDebounced, watchDebounced } from '@vueuse/core'
|
||||
import Fuse from 'fuse.js'
|
||||
import type { IFuseOptions } from 'fuse.js'
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
|
||||
import {
|
||||
createTemplateSearchIndex,
|
||||
searchTemplates
|
||||
} from '@/composables/templateSearchConfig'
|
||||
import { useSettingStore } from '@/platform/settings/settingStore'
|
||||
import { useTelemetry } from '@/platform/telemetry'
|
||||
import { useSearchQueryTracking } from '@/platform/telemetry/searchQuery/useSearchQueryTracking'
|
||||
@@ -14,40 +12,7 @@ import type { TemplateInfo } from '@/platform/workflow/templates/types/template'
|
||||
import { useSystemStatsStore } from '@/stores/systemStatsStore'
|
||||
import { useTemplateRankingStore } from '@/stores/templateRankingStore'
|
||||
import { debounce } from 'es-toolkit/compat'
|
||||
|
||||
type TemplateBrowseSort =
|
||||
| 'default'
|
||||
| 'recommended'
|
||||
| 'popular'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
| 'model-size-low-to-high'
|
||||
|
||||
type TemplateSortMode = TemplateBrowseSort | 'relevance'
|
||||
|
||||
/** The title shown on the card, trimmed for stable sorting. */
|
||||
function displayTitle(template: TemplateInfo): string {
|
||||
return (
|
||||
template.localizedTitle ||
|
||||
template.title ||
|
||||
template.name ||
|
||||
''
|
||||
).trim()
|
||||
}
|
||||
|
||||
/** A→Z by displayed title, with number-prefixed titles grouped after letters. */
|
||||
function compareAlphabetical(a: TemplateInfo, b: TemplateInfo): number {
|
||||
const titleA = displayTitle(a)
|
||||
const titleB = displayTitle(b)
|
||||
const numericA = /^\d/.test(titleA)
|
||||
const numericB = /^\d/.test(titleB)
|
||||
if (numericA !== numericB) return numericA ? 1 : -1
|
||||
return titleA.localeCompare(titleB, undefined, {
|
||||
numeric: true,
|
||||
sensitivity: 'base'
|
||||
})
|
||||
}
|
||||
import { api } from '@/scripts/api'
|
||||
|
||||
/**
|
||||
* Checks whether a template is visible for the given set of distributions.
|
||||
@@ -61,6 +26,20 @@ function isTemplateVisibleForDistributions(
|
||||
return distributions.some((d) => template.includeOnDistributions!.includes(d))
|
||||
}
|
||||
|
||||
// Fuse.js configuration for fuzzy search
|
||||
const defaultFuseOptions: IFuseOptions<TemplateInfo> = {
|
||||
keys: [
|
||||
{ name: 'name', weight: 0.3 },
|
||||
{ name: 'title', weight: 0.3 },
|
||||
{ name: 'description', weight: 0.1 },
|
||||
{ name: 'tags', weight: 0.2 },
|
||||
{ name: 'models', weight: 0.3 }
|
||||
],
|
||||
threshold: 0.33,
|
||||
includeScore: true,
|
||||
includeMatches: true
|
||||
}
|
||||
|
||||
export function useTemplateFiltering(
|
||||
templates: Ref<TemplateInfo[]> | TemplateInfo[]
|
||||
) {
|
||||
@@ -78,9 +57,17 @@ export function useTemplateFiltering(
|
||||
const selectedRunsOn = ref<string[]>(
|
||||
settingStore.get('Comfy.Templates.SelectedRunsOn')
|
||||
)
|
||||
const sortBy = ref<TemplateBrowseSort>(
|
||||
settingStore.get('Comfy.Templates.SortBy')
|
||||
)
|
||||
const sortBy = ref<
|
||||
| 'default'
|
||||
| 'recommended'
|
||||
| 'popular'
|
||||
| 'alphabetical'
|
||||
| 'newest'
|
||||
| 'vram-low-to-high'
|
||||
| 'model-size-low-to-high'
|
||||
>(settingStore.get('Comfy.Templates.SortBy'))
|
||||
|
||||
const fuseOptions = ref<IFuseOptions<TemplateInfo>>(defaultFuseOptions)
|
||||
|
||||
const templatesArray = computed(() => {
|
||||
const templateData = 'value' in templates ? templates.value : templates
|
||||
@@ -114,8 +101,8 @@ export function useTemplateFiltering(
|
||||
)
|
||||
})
|
||||
|
||||
const searchIndex = computed(() =>
|
||||
createTemplateSearchIndex(visibleTemplates.value)
|
||||
const fuse = computed(
|
||||
() => new Fuse(visibleTemplates.value, fuseOptions.value)
|
||||
)
|
||||
|
||||
const availableModels = computed(() => {
|
||||
@@ -167,36 +154,15 @@ export function useTemplateFiltering(
|
||||
)
|
||||
)
|
||||
|
||||
const hasActiveQuery = computed(() => searchQuery.value.trim().length > 0)
|
||||
const searchSort = ref<TemplateSortMode>('relevance')
|
||||
watch(hasActiveQuery, (searching) => {
|
||||
if (searching) searchSort.value = 'relevance'
|
||||
})
|
||||
const activeSort = computed(() =>
|
||||
hasActiveQuery.value ? searchSort.value : sortBy.value
|
||||
)
|
||||
|
||||
const sortSelection = computed<TemplateSortMode>({
|
||||
get: () => activeSort.value,
|
||||
set: (value) => {
|
||||
// relevance is search-only; a browse sort chosen mid-search stays ephemeral.
|
||||
if (value === 'relevance' || hasActiveQuery.value)
|
||||
searchSort.value = value
|
||||
else sortBy.value = value
|
||||
}
|
||||
})
|
||||
const debouncedSearchQuery = refDebounced(searchQuery, 150)
|
||||
|
||||
const filteredBySearch = computed(() => {
|
||||
if (!hasActiveQuery.value) {
|
||||
if (!debouncedSearchQuery.value.trim()) {
|
||||
return visibleTemplates.value
|
||||
}
|
||||
|
||||
const templatesByName = new Map(
|
||||
visibleTemplates.value.map((template) => [template.name, template])
|
||||
)
|
||||
return searchTemplates(searchIndex.value, searchQuery.value)
|
||||
.map((name) => templatesByName.get(name))
|
||||
.filter((template): template is TemplateInfo => template !== undefined)
|
||||
const results = fuse.value.search(debouncedSearchQuery.value)
|
||||
return results.map((result) => result.item)
|
||||
})
|
||||
|
||||
const filteredByModels = computed(() => {
|
||||
@@ -258,9 +224,8 @@ export function useTemplateFiltering(
|
||||
watch(
|
||||
filteredByRunsOn,
|
||||
(templates) => {
|
||||
rankingStore.largestUsageScore = templates.reduce(
|
||||
(max, template) => Math.max(max, template.usage ?? 0),
|
||||
0
|
||||
rankingStore.largestUsageScore = Math.max(
|
||||
...templates.map((t) => t.usage || 0)
|
||||
)
|
||||
},
|
||||
{ immediate: true }
|
||||
@@ -269,9 +234,7 @@ export function useTemplateFiltering(
|
||||
const sortedTemplates = computed(() => {
|
||||
const templates = [...filteredByRunsOn.value]
|
||||
|
||||
switch (activeSort.value) {
|
||||
case 'relevance':
|
||||
return templates
|
||||
switch (sortBy.value) {
|
||||
case 'recommended':
|
||||
// Curated: usage × 0.5 + internal × 0.3 + freshness × 0.2
|
||||
return templates.sort((a, b) => {
|
||||
@@ -288,9 +251,18 @@ export function useTemplateFiltering(
|
||||
return scoreB - scoreA
|
||||
})
|
||||
case 'popular':
|
||||
return templates.sort((a, b) => (b.usage ?? 0) - (a.usage ?? 0))
|
||||
// User-driven: usage × 0.9 + freshness × 0.1
|
||||
return templates.sort((a, b) => {
|
||||
const scoreA = rankingStore.computePopularScore(a.date, a.usage)
|
||||
const scoreB = rankingStore.computePopularScore(b.date, b.usage)
|
||||
return scoreB - scoreA
|
||||
})
|
||||
case 'alphabetical':
|
||||
return templates.sort(compareAlphabetical)
|
||||
return templates.sort((a, b) => {
|
||||
const nameA = a.title || a.name || ''
|
||||
const nameB = b.title || b.name || ''
|
||||
return nameA.localeCompare(nameB)
|
||||
})
|
||||
case 'newest':
|
||||
return templates.sort((a, b) => {
|
||||
const dateA = new Date(a.date || '1970-01-01')
|
||||
@@ -320,7 +292,6 @@ export function useTemplateFiltering(
|
||||
selectedUseCases.value = []
|
||||
selectedRunsOn.value = []
|
||||
sortBy.value = 'default'
|
||||
searchSort.value = 'relevance'
|
||||
}
|
||||
|
||||
const removeModelFilter = (model: string) => {
|
||||
@@ -346,15 +317,22 @@ export function useTemplateFiltering(
|
||||
selected_models: selectedModels.value,
|
||||
selected_use_cases: selectedUseCases.value,
|
||||
selected_runs_on: selectedRunsOn.value,
|
||||
sort_by: activeSort.value,
|
||||
sort_by: sortBy.value,
|
||||
filtered_count: filteredCount.value,
|
||||
total_count: totalCount.value
|
||||
})
|
||||
}, 500)
|
||||
|
||||
const loadFuseOptions = async () => {
|
||||
const fetchedOptions = await api.getFuseOptions()
|
||||
if (fetchedOptions) {
|
||||
fuseOptions.value = fetchedOptions
|
||||
}
|
||||
}
|
||||
|
||||
// Watch for filter changes and track them
|
||||
watch(
|
||||
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, activeSort],
|
||||
[searchQuery, selectedModels, selectedUseCases, selectedRunsOn, sortBy],
|
||||
() => {
|
||||
// Only track if at least one filter is active (to avoid tracking initial state)
|
||||
const hasActiveFilters =
|
||||
@@ -362,7 +340,7 @@ export function useTemplateFiltering(
|
||||
selectedModels.value.length > 0 ||
|
||||
selectedUseCases.value.length > 0 ||
|
||||
selectedRunsOn.value.length > 0 ||
|
||||
activeSort.value !== 'default'
|
||||
sortBy.value !== 'default'
|
||||
|
||||
if (hasActiveFilters) {
|
||||
debouncedTrackFilterChange()
|
||||
@@ -411,8 +389,6 @@ export function useTemplateFiltering(
|
||||
selectedUseCases,
|
||||
selectedRunsOn,
|
||||
sortBy,
|
||||
sortSelection,
|
||||
hasActiveQuery,
|
||||
|
||||
// Computed - Active filters (actually applied)
|
||||
activeModels,
|
||||
@@ -434,6 +410,7 @@ export function useTemplateFiltering(
|
||||
resetFilters,
|
||||
removeModelFilter,
|
||||
removeUseCaseFilter,
|
||||
removeRunsOnFilter
|
||||
removeRunsOnFilter,
|
||||
loadFuseOptions
|
||||
}
|
||||
}
|
||||
|
||||
@@ -32,8 +32,7 @@ function makeNode(connected: boolean, comfyClass = 'CreateBoundingBoxes') {
|
||||
const widgets: MockWidget[] = [
|
||||
{ name: 'width', hidden: false, options: {} },
|
||||
{ name: 'height', hidden: false, options: {} },
|
||||
{ name: 'other', hidden: false, options: {} },
|
||||
{ name: 'last_incoming', hidden: false, options: {} }
|
||||
{ name: 'other', hidden: false, options: {} }
|
||||
]
|
||||
return {
|
||||
constructor: { comfyClass },
|
||||
@@ -74,15 +73,6 @@ describe('Comfy.CreateBoundingBoxes extension', () => {
|
||||
expect(node.widgets[0].options.hidden).toBe(false)
|
||||
})
|
||||
|
||||
it('always hides the internal last_incoming widget', () => {
|
||||
for (const connected of [true, false]) {
|
||||
const node = makeNode(connected)
|
||||
state.extension!.nodeCreated(node)
|
||||
expect(node.widgets[3].hidden).toBe(true)
|
||||
expect(node.widgets[3].options.hidden).toBe(true)
|
||||
}
|
||||
})
|
||||
|
||||
it('writes visibility through the widget value store when present', () => {
|
||||
state.widgetState = { options: {} }
|
||||
const node = makeNode(true)
|
||||
|
||||
@@ -3,7 +3,6 @@ import { useExtensionService } from '@/services/extensionService'
|
||||
import { useWidgetValueStore } from '@/stores/widgetValueStore'
|
||||
|
||||
const DIMENSION_WIDGETS = new Set(['width', 'height'])
|
||||
const INTERNAL_WIDGETS = new Set(['last_incoming'])
|
||||
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.CreateBoundingBoxes',
|
||||
@@ -16,30 +15,20 @@ useExtensionService().registerExtension({
|
||||
|
||||
const widgetValueStore = useWidgetValueStore()
|
||||
|
||||
const setWidgetHidden = (
|
||||
widget: NonNullable<typeof node.widgets>[number],
|
||||
hidden: boolean
|
||||
) => {
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
}
|
||||
|
||||
const syncDimensionVisibility = () => {
|
||||
const slot = node.findInputSlot('background')
|
||||
const hidden = slot >= 0 && node.isInputConnected(slot)
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (DIMENSION_WIDGETS.has(widget.name)) setWidgetHidden(widget, hidden)
|
||||
if (!DIMENSION_WIDGETS.has(widget.name)) continue
|
||||
widget.hidden = hidden
|
||||
const state = widget.widgetId
|
||||
? widgetValueStore.getWidget(widget.widgetId)
|
||||
: undefined
|
||||
if (state?.options) state.options.hidden = hidden
|
||||
else widget.options.hidden = hidden
|
||||
}
|
||||
}
|
||||
|
||||
for (const widget of node.widgets ?? []) {
|
||||
if (INTERNAL_WIDGETS.has(widget.name)) setWidgetHidden(widget, true)
|
||||
}
|
||||
|
||||
syncDimensionVisibility()
|
||||
node.onConnectionsChange = useChainCallback(
|
||||
node.onConnectionsChange,
|
||||
|
||||
@@ -143,23 +143,14 @@ async function loadExtensionsFresh(): Promise<{
|
||||
load3DExt: ExtCreated
|
||||
preview3DExt: ExtCreated
|
||||
preview3DAdvancedExt: ExtCreated
|
||||
save3DAdvancedExt: ExtCreated
|
||||
}> {
|
||||
vi.resetModules()
|
||||
registerExtensionMock.mockClear()
|
||||
await import('@/extensions/core/load3d')
|
||||
const extByName = (name: string): ExtCreated => {
|
||||
const call = registerExtensionMock.mock.calls.find(
|
||||
(c) => (c[0] as ExtCreated).name === name
|
||||
)
|
||||
if (!call) throw new Error(`Extension ${name} was not registered`)
|
||||
return call[0] as ExtCreated
|
||||
}
|
||||
return {
|
||||
load3DExt: extByName('Comfy.Load3D'),
|
||||
preview3DExt: extByName('Comfy.Preview3D'),
|
||||
preview3DAdvancedExt: extByName('Comfy.Preview3DAdvanced'),
|
||||
save3DAdvancedExt: extByName('Comfy.Save3DAdvanced')
|
||||
load3DExt: registerExtensionMock.mock.calls[0][0] as ExtCreated,
|
||||
preview3DExt: registerExtensionMock.mock.calls[1][0] as ExtCreated,
|
||||
preview3DAdvancedExt: registerExtensionMock.mock.calls[2][0] as ExtCreated
|
||||
}
|
||||
}
|
||||
|
||||
@@ -273,15 +264,14 @@ function setupBaseMocks() {
|
||||
describe('load3d module registration', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, Comfy.Preview3DAdvanced, and Comfy.Save3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt, save3DAdvancedExt } =
|
||||
it('registers Comfy.Load3D, Comfy.Preview3D, and Comfy.Preview3DAdvanced extensions on import', async () => {
|
||||
const { load3DExt, preview3DExt, preview3DAdvancedExt } =
|
||||
await loadExtensionsFresh()
|
||||
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(4)
|
||||
expect(registerExtensionMock).toHaveBeenCalledTimes(3)
|
||||
expect(load3DExt.name).toBe('Comfy.Load3D')
|
||||
expect(preview3DExt.name).toBe('Comfy.Preview3D')
|
||||
expect(preview3DAdvancedExt.name).toBe('Comfy.Preview3DAdvanced')
|
||||
expect(save3DAdvancedExt.name).toBe('Comfy.Save3DAdvanced')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -721,39 +711,6 @@ describe('Comfy.Preview3D.onNodeOutputsUpdated', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.onNodeOutputsUpdated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('restores the saved model from the output folder when opened from history', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['3d\\ComfyUI_00001.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(node.properties['Last Time Model File']).toBe('3d/ComfyUI_00001.glb')
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001.glb',
|
||||
expect.objectContaining({ silentOnNotFound: true })
|
||||
)
|
||||
})
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
getNodeByLocatorIdMock.mockReturnValue(node)
|
||||
|
||||
save3DAdvancedExt.onNodeOutputsUpdated!({
|
||||
'7': { result: ['mesh.glb'] }
|
||||
} as never)
|
||||
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Preview3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
@@ -1075,50 +1032,6 @@ describe('Comfy.Preview3DAdvanced.getNodeMenuItems', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Save3DAdvanced.nodeCreated', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
it('skips nodes whose comfyClass is not Save3DAdvanced', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Preview3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(waitForLoad3dMock).not.toHaveBeenCalled()
|
||||
expect(configureForSaveMeshMock).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('restores persisted models from the output folder, not temp', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({
|
||||
comfyClass: 'Save3DAdvanced',
|
||||
properties: { 'Last Time Model File': '3d/ComfyUI_00001_.glb' }
|
||||
})
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00001_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
|
||||
it('onExecuted loads the saved file from the output folder', async () => {
|
||||
const { save3DAdvancedExt } = await loadExtensionsFresh()
|
||||
const node = makePreview3DAdvancedNode({ comfyClass: 'Save3DAdvanced' })
|
||||
|
||||
await save3DAdvancedExt.nodeCreated(node)
|
||||
node.onExecuted!({ result: ['3d/ComfyUI_00002_.glb'] })
|
||||
|
||||
expect(configureForSaveMeshMock).toHaveBeenCalledWith(
|
||||
'output',
|
||||
'3d/ComfyUI_00002_.glb',
|
||||
{ silentOnNotFound: true }
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('Comfy.Load3D scene widget serializeValue caching', () => {
|
||||
beforeEach(setupBaseMocks)
|
||||
|
||||
|
||||
@@ -15,10 +15,8 @@ import { createExportMenuItems } from '@/extensions/core/load3d/exportMenuHelper
|
||||
import type {
|
||||
CameraConfig,
|
||||
CameraState,
|
||||
LoadFolder,
|
||||
Model3DInfo
|
||||
} from '@/extensions/core/load3d/interfaces'
|
||||
import type Load3d from '@/extensions/core/load3d/Load3d'
|
||||
import Load3DConfiguration from '@/extensions/core/load3d/Load3DConfiguration'
|
||||
import {
|
||||
LOAD3D_NONE_MODEL,
|
||||
@@ -50,7 +48,6 @@ import { ComponentWidgetImpl, addWidget } from '@/scripts/domWidget'
|
||||
import { useExtensionService } from '@/services/extensionService'
|
||||
import { useLoad3dService } from '@/services/load3dService'
|
||||
import { useDialogStore } from '@/stores/dialogStore'
|
||||
import type { ComfyExtension } from '@/types/comfy'
|
||||
import { isLoad3dNode } from '@/utils/litegraphUtil'
|
||||
|
||||
const inputSpecLoad3D: CustomInputSpec = {
|
||||
@@ -290,11 +287,8 @@ useExtensionService().registerExtension({
|
||||
getCustomWidgets() {
|
||||
const VIEWPORT_STATE_NODES = new Set([
|
||||
'Preview3DAdvanced',
|
||||
'Save3DAdvanced',
|
||||
'PreviewGaussianSplat',
|
||||
'PreviewPointCloud',
|
||||
'SaveGaussianSplat',
|
||||
'SavePointCloud'
|
||||
'PreviewPointCloud'
|
||||
])
|
||||
return {
|
||||
LOAD_3D(node) {
|
||||
@@ -685,215 +679,155 @@ useExtensionService().registerExtension({
|
||||
}
|
||||
})
|
||||
|
||||
function applyPreview3DAdvancedResult(
|
||||
node: LGraphNode,
|
||||
load3d: Load3d,
|
||||
result: NonNullable<Preview3DAdvancedOutput['result']>,
|
||||
loadFolder: LoadFolder,
|
||||
comfyClass: string
|
||||
): void {
|
||||
const filePath = result[0]
|
||||
if (!filePath) return
|
||||
useExtensionService().registerExtension({
|
||||
name: 'Comfy.Preview3DAdvanced',
|
||||
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return []
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
|
||||
const cameraState = result[1]
|
||||
const modelTransform = result[2]?.[0]
|
||||
if (!cameraState && !modelTransform) return
|
||||
if (load3d.isSplatModel()) return []
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
if (cameraState) load3d.setCameraState(cameraState)
|
||||
if (modelTransform) load3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to apply input camera_info / model_3d_info from ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
|
||||
function createPreview3DAdvancedExtension(
|
||||
comfyClass: string,
|
||||
extensionName: string,
|
||||
loadFolder: LoadFolder
|
||||
): ComfyExtension {
|
||||
return {
|
||||
name: extensionName,
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== 'Preview3DAdvanced') return
|
||||
|
||||
onNodeOutputsUpdated(
|
||||
nodeOutputs: Record<NodeLocatorId, NodeExecutionOutput>
|
||||
) {
|
||||
for (const [locatorId, output] of Object.entries(nodeOutputs)) {
|
||||
const result = (output as Preview3DAdvancedOutput).result
|
||||
if (!result?.[0]) continue
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
|
||||
const node = getNodeByLocatorId(app.rootGraph, locatorId)
|
||||
if (!node || node.constructor.comfyClass !== comfyClass) continue
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
load3d,
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
await nextTick()
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
|
||||
useLoad3d(node).onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh('temp', lastTimeModelFile as string, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to restore camera state for Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
useLoad3d(node).waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find((w) => w.name === 'viewport_state')
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
getNodeMenuItems(node: LGraphNode): (IContextMenuValue | null)[] {
|
||||
if (node.constructor.comfyClass !== comfyClass) return []
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const load3d = useLoad3dService().getLoad3d(node)
|
||||
if (!load3d) return []
|
||||
const cameraConfig: CameraConfig = (node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
if (load3d.isSplatModel()) return []
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return createExportMenuItems(load3d)
|
||||
},
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
async nodeCreated(node: LGraphNode) {
|
||||
if (node.constructor.comfyClass !== comfyClass) return
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const [oldWidth, oldHeight] = node.size
|
||||
const result = output.result
|
||||
const filePath = result?.[0]
|
||||
|
||||
node.setSize([Math.max(oldWidth, 400), Math.max(oldHeight, 550)])
|
||||
if (!filePath) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
await nextTick()
|
||||
const normalizedPath = filePath.replaceAll('\\', '/')
|
||||
node.properties['Last Time Model File'] = normalizedPath
|
||||
|
||||
const onExecuted = node.onExecuted
|
||||
const { onLoad3dReady, waitForLoad3d } = useLoad3d(node)
|
||||
|
||||
onLoad3dReady((load3d) => {
|
||||
const lastTimeModelFile = node.properties['Last Time Model File']
|
||||
if (!lastTimeModelFile) return
|
||||
|
||||
const config = new Load3DConfiguration(load3d, node.properties)
|
||||
config.configureForSaveMesh(loadFolder, lastTimeModelFile as string, {
|
||||
const currentLoad3d = resolveLoad3d()
|
||||
const config = new Load3DConfiguration(currentLoad3d, node.properties)
|
||||
config.configureForSaveMesh('temp', normalizedPath, {
|
||||
silentOnNotFound: true
|
||||
})
|
||||
|
||||
const cameraConfig = node.properties['Camera Config'] as
|
||||
| CameraConfig
|
||||
| undefined
|
||||
const cameraState = cameraConfig?.state
|
||||
if (!cameraState) return
|
||||
|
||||
const targetGeneration = load3d.currentLoadGeneration
|
||||
void load3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (load3d.currentLoadGeneration !== targetGeneration) return
|
||||
load3d.setCameraState(cameraState)
|
||||
load3d.forceRender()
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
`Failed to restore camera state for ${comfyClass}:`,
|
||||
error
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
waitForLoad3d((load3d) => {
|
||||
const sceneWidget = node.widgets?.find(
|
||||
(w) => w.name === 'viewport_state'
|
||||
)
|
||||
if (!sceneWidget) return
|
||||
|
||||
const resolveLoad3d = () => nodeToLoad3dMap.get(node) ?? load3d
|
||||
|
||||
const widthWidget = node.widgets?.find((w) => w.name === 'width')
|
||||
const heightWidget = node.widgets?.find((w) => w.name === 'height')
|
||||
if (widthWidget && heightWidget) {
|
||||
load3d.setTargetSize(
|
||||
widthWidget.value as number,
|
||||
heightWidget.value as number
|
||||
)
|
||||
widthWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(value, heightWidget.value as number)
|
||||
}
|
||||
heightWidget.callback = (value: number) => {
|
||||
resolveLoad3d().setTargetSize(widthWidget.value as number, value)
|
||||
}
|
||||
const cameraState = result?.[1]
|
||||
const modelTransform = result?.[2]?.[0]
|
||||
if (cameraState || modelTransform) {
|
||||
const targetGeneration = currentLoad3d.currentLoadGeneration
|
||||
void currentLoad3d
|
||||
.whenLoadIdle()
|
||||
.then(() => {
|
||||
if (currentLoad3d.currentLoadGeneration !== targetGeneration)
|
||||
return
|
||||
if (cameraState) currentLoad3d.setCameraState(cameraState)
|
||||
if (modelTransform)
|
||||
currentLoad3d.applyModelTransform(modelTransform)
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error(
|
||||
'Failed to apply input camera_info / model_3d_info from Preview3DAdvanced:',
|
||||
error
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
sceneWidget.serializeValue = async () => {
|
||||
const currentLoad3d = nodeToLoad3dMap.get(node)
|
||||
if (!currentLoad3d) {
|
||||
console.error('No load3d instance found for node')
|
||||
return null
|
||||
}
|
||||
|
||||
const cameraConfig: CameraConfig = (node.properties[
|
||||
'Camera Config'
|
||||
] as CameraConfig | undefined) || {
|
||||
cameraType: currentLoad3d.getCurrentCameraType(),
|
||||
fov: currentLoad3d.cameraManager.perspectiveCamera.fov
|
||||
}
|
||||
cameraConfig.state = currentLoad3d.getCameraState()
|
||||
node.properties['Camera Config'] = cameraConfig
|
||||
|
||||
const modelInfo = currentLoad3d.getModelInfo()
|
||||
const model_3d_info: Model3DInfo = modelInfo ? [modelInfo] : []
|
||||
|
||||
return {
|
||||
image: '',
|
||||
mask: '',
|
||||
normal: '',
|
||||
camera_info: cameraConfig.state || null,
|
||||
recording: '',
|
||||
model_3d_info
|
||||
}
|
||||
}
|
||||
|
||||
node.onExecuted = function (output: Preview3DAdvancedOutput) {
|
||||
onExecuted?.call(this, output)
|
||||
|
||||
const result = output.result
|
||||
if (!result?.[0]) {
|
||||
const msg = t('toastMessages.unableToGetModelFilePath')
|
||||
console.error(msg)
|
||||
useToastStore().addAlert(msg)
|
||||
return
|
||||
}
|
||||
|
||||
applyPreview3DAdvancedResult(
|
||||
node,
|
||||
resolveLoad3d(),
|
||||
result,
|
||||
loadFolder,
|
||||
comfyClass
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Preview3DAdvanced',
|
||||
'Comfy.Preview3DAdvanced',
|
||||
'temp'
|
||||
)
|
||||
)
|
||||
useExtensionService().registerExtension(
|
||||
createPreview3DAdvancedExtension(
|
||||
'Save3DAdvanced',
|
||||
'Comfy.Save3DAdvanced',
|
||||
'output'
|
||||
)
|
||||
)
|
||||
})
|
||||
|
||||
@@ -43,8 +43,13 @@ function makeMockEventManager() {
|
||||
} satisfies EventManagerInterface
|
||||
}
|
||||
|
||||
function makeElement() {
|
||||
return document.createElement('div')
|
||||
function makeRenderer(opts: { withParent?: boolean } = {}) {
|
||||
const canvas = document.createElement('canvas')
|
||||
if (opts.withParent) {
|
||||
const parent = document.createElement('div')
|
||||
parent.appendChild(canvas)
|
||||
}
|
||||
return { domElement: canvas } as unknown as THREE.WebGLRenderer
|
||||
}
|
||||
|
||||
describe('ControlsManager', () => {
|
||||
@@ -59,19 +64,33 @@ describe('ControlsManager', () => {
|
||||
})
|
||||
|
||||
describe('construction', () => {
|
||||
it('attaches OrbitControls to the interaction element', () => {
|
||||
const element = makeElement()
|
||||
it('attaches OrbitControls to the canvas parent when one exists', () => {
|
||||
const renderer = makeRenderer({ withParent: true })
|
||||
|
||||
manager = new ControlsManager(element, camera, events)
|
||||
manager = new ControlsManager(renderer, camera, events)
|
||||
|
||||
expect(mockOrbitControls).toHaveBeenCalledWith(camera, element)
|
||||
expect(mockOrbitControls).toHaveBeenCalledWith(
|
||||
camera,
|
||||
renderer.domElement.parentElement
|
||||
)
|
||||
expect(manager.controls.enableDamping).toBe(true)
|
||||
})
|
||||
|
||||
it('falls back to the canvas itself when there is no parent', () => {
|
||||
const renderer = makeRenderer({ withParent: false })
|
||||
|
||||
manager = new ControlsManager(renderer, camera, events)
|
||||
|
||||
expect(mockOrbitControls).toHaveBeenCalledWith(
|
||||
camera,
|
||||
renderer.domElement
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('init', () => {
|
||||
it('emits cameraChanged with a perspective state when the controls fire end', () => {
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
camera.position.set(1, 2, 3)
|
||||
camera.zoom = 1.25
|
||||
manager.controls.target.set(4, 5, 6)
|
||||
@@ -90,7 +109,7 @@ describe('ControlsManager', () => {
|
||||
it('reports orthographic camera type when initialized with one', () => {
|
||||
const ortho = new THREE.OrthographicCamera()
|
||||
ortho.zoom = 0.5
|
||||
manager = new ControlsManager(makeElement(), ortho, events)
|
||||
manager = new ControlsManager(makeRenderer(), ortho, events)
|
||||
manager.init()
|
||||
|
||||
;(manager.controls as unknown as { fire(e: string): void }).fire('end')
|
||||
@@ -104,7 +123,7 @@ describe('ControlsManager', () => {
|
||||
|
||||
describe('updateCamera', () => {
|
||||
it('rebinds controls to the new camera, copies position from the previous one, and preserves the target', () => {
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
camera.position.set(7, 8, 9)
|
||||
manager.controls.target.set(1, 1, 1)
|
||||
|
||||
@@ -120,7 +139,7 @@ describe('ControlsManager', () => {
|
||||
|
||||
describe('update / reset', () => {
|
||||
it('update delegates to controls.update', () => {
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
|
||||
manager.update()
|
||||
|
||||
@@ -128,7 +147,7 @@ describe('ControlsManager', () => {
|
||||
})
|
||||
|
||||
it('reset clears the target back to the origin and refreshes', () => {
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager.controls.target.set(5, 6, 7)
|
||||
|
||||
manager.reset()
|
||||
@@ -140,7 +159,7 @@ describe('ControlsManager', () => {
|
||||
|
||||
describe('dispose', () => {
|
||||
it('disposes the underlying OrbitControls', () => {
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
|
||||
manager.dispose()
|
||||
|
||||
@@ -150,7 +169,7 @@ describe('ControlsManager', () => {
|
||||
|
||||
describe('detach / attach', () => {
|
||||
it('detach disables OrbitControls interaction', () => {
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
expect(manager.controls.enabled).toBe(true)
|
||||
|
||||
manager.detach()
|
||||
@@ -159,7 +178,7 @@ describe('ControlsManager', () => {
|
||||
})
|
||||
|
||||
it('attach re-enables OrbitControls interaction', () => {
|
||||
manager = new ControlsManager(makeElement(), camera, events)
|
||||
manager = new ControlsManager(makeRenderer(), camera, events)
|
||||
manager.detach()
|
||||
|
||||
manager.attach()
|
||||
|
||||
@@ -12,14 +12,15 @@ export class ControlsManager implements ControlsManagerInterface {
|
||||
private camera: THREE.Camera
|
||||
|
||||
constructor(
|
||||
interactionElement: HTMLElement,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
camera: THREE.Camera,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.eventManager = eventManager
|
||||
this.camera = camera
|
||||
|
||||
this.controls = new OrbitControls(camera, interactionElement)
|
||||
const container = renderer.domElement.parentElement || renderer.domElement
|
||||
this.controls = new OrbitControls(camera, container)
|
||||
this.controls.enableDamping = true
|
||||
}
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ function makeMockOrbitControls() {
|
||||
|
||||
describe('GizmoManager', () => {
|
||||
let scene: THREE.Scene
|
||||
let interactionElement: HTMLElement
|
||||
let renderer: THREE.WebGLRenderer
|
||||
let camera: THREE.PerspectiveCamera
|
||||
let orbitControls: ReturnType<typeof makeMockOrbitControls>
|
||||
let manager: GizmoManager
|
||||
@@ -66,7 +66,9 @@ describe('GizmoManager', () => {
|
||||
vi.clearAllMocks()
|
||||
|
||||
scene = new THREE.Scene()
|
||||
interactionElement = document.createElement('div')
|
||||
renderer = {
|
||||
domElement: document.createElement('canvas')
|
||||
} as unknown as THREE.WebGLRenderer
|
||||
camera = new THREE.PerspectiveCamera()
|
||||
orbitControls = makeMockOrbitControls()
|
||||
onTransformChange = vi.fn()
|
||||
@@ -78,7 +80,7 @@ describe('GizmoManager', () => {
|
||||
|
||||
manager = new GizmoManager(
|
||||
scene,
|
||||
interactionElement,
|
||||
renderer,
|
||||
orbitControls,
|
||||
() => camera,
|
||||
onTransformChange
|
||||
|
||||
@@ -15,19 +15,19 @@ export class GizmoManager {
|
||||
private activeCamera: THREE.Camera
|
||||
private mode: GizmoMode = 'translate'
|
||||
private scene: THREE.Scene
|
||||
private interactionElement: HTMLElement
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private orbitControls: OrbitControls
|
||||
private onTransformChange?: () => void
|
||||
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
interactionElement: HTMLElement,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
orbitControls: OrbitControls,
|
||||
getActiveCamera: () => THREE.Camera,
|
||||
onTransformChange?: () => void
|
||||
) {
|
||||
this.scene = scene
|
||||
this.interactionElement = interactionElement
|
||||
this.renderer = renderer
|
||||
this.orbitControls = orbitControls
|
||||
this.activeCamera = getActiveCamera()
|
||||
this.onTransformChange = onTransformChange
|
||||
@@ -36,7 +36,7 @@ export class GizmoManager {
|
||||
init(): void {
|
||||
this.transformControls = new TransformControls(
|
||||
this.activeCamera,
|
||||
this.interactionElement
|
||||
this.renderer.domElement
|
||||
)
|
||||
|
||||
this.transformControls.addEventListener('dragging-changed', (event) => {
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
import * as THREE from 'three'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { createRendererViewState } from '@/renderer/three/sharedWebGLRenderer'
|
||||
|
||||
import { HDRIManager } from './HDRIManager'
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
|
||||
@@ -78,12 +76,7 @@ describe('HDRIManager', () => {
|
||||
dispose: vi.fn()
|
||||
})
|
||||
|
||||
manager = new HDRIManager(
|
||||
scene,
|
||||
{} as THREE.WebGLRenderer,
|
||||
createRendererViewState(),
|
||||
eventManager
|
||||
)
|
||||
manager = new HDRIManager(scene, {} as THREE.WebGLRenderer, eventManager)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
|
||||
@@ -2,14 +2,12 @@ import * as THREE from 'three'
|
||||
import { EXRLoader } from 'three/examples/jsm/loaders/EXRLoader'
|
||||
import { RGBELoader } from 'three/examples/jsm/loaders/RGBELoader'
|
||||
|
||||
import type { RendererViewState } from '@/renderer/three/sharedWebGLRenderer'
|
||||
|
||||
import Load3dUtils from './Load3dUtils'
|
||||
import type { EventManagerInterface } from './interfaces'
|
||||
|
||||
export class HDRIManager {
|
||||
private scene: THREE.Scene
|
||||
private viewState: RendererViewState
|
||||
private renderer: THREE.WebGLRenderer
|
||||
private pmremGenerator: THREE.PMREMGenerator
|
||||
private eventManager: EventManagerInterface
|
||||
|
||||
@@ -35,11 +33,10 @@ export class HDRIManager {
|
||||
constructor(
|
||||
scene: THREE.Scene,
|
||||
renderer: THREE.WebGLRenderer,
|
||||
viewState: RendererViewState,
|
||||
eventManager: EventManagerInterface
|
||||
) {
|
||||
this.scene = scene
|
||||
this.viewState = viewState
|
||||
this.renderer = renderer
|
||||
this.pmremGenerator = new THREE.PMREMGenerator(renderer)
|
||||
this.pmremGenerator.compileEquirectangularShader()
|
||||
this.eventManager = eventManager
|
||||
@@ -104,8 +101,8 @@ export class HDRIManager {
|
||||
this.scene.environment = envMap
|
||||
this.scene.environmentIntensity = this._intensity
|
||||
this.scene.background = this._showAsBackground ? this.hdriTexture : null
|
||||
this.viewState.toneMapping = THREE.ACESFilmicToneMapping
|
||||
this.viewState.toneMappingExposure = 1.0
|
||||
this.renderer.toneMapping = THREE.ACESFilmicToneMapping
|
||||
this.renderer.toneMappingExposure = 1.0
|
||||
this.eventManager.emitEvent('hdriChange', {
|
||||
enabled: this._isEnabled,
|
||||
showAsBackground: this._showAsBackground
|
||||
@@ -117,8 +114,8 @@ export class HDRIManager {
|
||||
if (this.scene.background === this.hdriTexture) {
|
||||
this.scene.background = null
|
||||
}
|
||||
this.viewState.toneMapping = THREE.NoToneMapping
|
||||
this.viewState.toneMappingExposure = 1.0
|
||||
this.renderer.toneMapping = THREE.NoToneMapping
|
||||
this.renderer.toneMappingExposure = 1.0
|
||||
this.eventManager.emitEvent('hdriChange', {
|
||||
enabled: false,
|
||||
showAsBackground: this._showAsBackground
|
||||
|
||||
@@ -334,7 +334,7 @@ describe('Load3d', () => {
|
||||
const sceneResize = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
view: { canvas, setSize },
|
||||
renderer: { domElement: canvas, setSize, setPixelRatio: vi.fn() },
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
targetAspectRatio: 2,
|
||||
@@ -361,21 +361,26 @@ describe('Load3d', () => {
|
||||
const updateAspectRatio = vi.fn()
|
||||
const renderBackground = vi.fn()
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
Object.defineProperty(canvas, 'clientWidth', {
|
||||
value: 800,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(canvas, 'clientHeight', {
|
||||
value: 600,
|
||||
configurable: true
|
||||
})
|
||||
const scene = {} as THREE.Scene
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
view: {
|
||||
width: 800,
|
||||
height: 600,
|
||||
state: { clearColor: new THREE.Color(0x000000), clearAlpha: 0 },
|
||||
renderer: {
|
||||
setViewport,
|
||||
setScissor,
|
||||
setScissorTest,
|
||||
setClearColor,
|
||||
clear,
|
||||
render
|
||||
}
|
||||
renderer: {
|
||||
domElement: canvas,
|
||||
setViewport,
|
||||
setScissor,
|
||||
setScissorTest,
|
||||
setClearColor,
|
||||
clear,
|
||||
render
|
||||
},
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
@@ -410,7 +415,7 @@ describe('Load3d', () => {
|
||||
})
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
view: { canvas },
|
||||
renderer: { domElement: canvas },
|
||||
targetWidth: 400,
|
||||
targetHeight: 200,
|
||||
targetAspectRatio: 2,
|
||||
@@ -433,7 +438,7 @@ describe('Load3d', () => {
|
||||
expect(args[3]).toBe(400)
|
||||
})
|
||||
|
||||
it('handleResize scales the view size by getZoomScaleCallback', () => {
|
||||
it('handleResize calls setPixelRatio with the value returned by getZoomScaleCallback', () => {
|
||||
delete (ctx.load3d as { handleResize?: unknown }).handleResize
|
||||
|
||||
const parent = document.createElement('div')
|
||||
@@ -448,10 +453,10 @@ describe('Load3d', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setSize = vi.fn()
|
||||
const setPixelRatio = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
view: { canvas, setSize },
|
||||
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
|
||||
getZoomScaleCallback: () => 2.5,
|
||||
targetWidth: 0,
|
||||
targetHeight: 0,
|
||||
@@ -462,10 +467,10 @@ describe('Load3d', () => {
|
||||
|
||||
ctx.load3d.handleResize()
|
||||
|
||||
expect(setSize).toHaveBeenCalledWith(1000, 1000)
|
||||
expect(setPixelRatio).toHaveBeenCalledWith(2.5)
|
||||
})
|
||||
|
||||
it('handleResize caps the zoom scale at 3', () => {
|
||||
it('handleResize defaults to pixelRatio 1 when no getZoomScaleCallback is provided', () => {
|
||||
delete (ctx.load3d as { handleResize?: unknown }).handleResize
|
||||
|
||||
const parent = document.createElement('div')
|
||||
@@ -480,42 +485,10 @@ describe('Load3d', () => {
|
||||
const canvas = document.createElement('canvas')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setSize = vi.fn()
|
||||
const setPixelRatio = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
view: { canvas, setSize },
|
||||
getZoomScaleCallback: () => 10,
|
||||
targetWidth: 0,
|
||||
targetHeight: 0,
|
||||
isViewerMode: false,
|
||||
cameraManager: { ...ctx.cameraManager, handleResize: vi.fn() },
|
||||
sceneManager: { ...ctx.sceneManager, handleResize: vi.fn() }
|
||||
})
|
||||
|
||||
ctx.load3d.handleResize()
|
||||
|
||||
expect(setSize).toHaveBeenCalledWith(1200, 1200)
|
||||
})
|
||||
|
||||
it('handleResize defaults to scale 1 when no getZoomScaleCallback is provided', () => {
|
||||
delete (ctx.load3d as { handleResize?: unknown }).handleResize
|
||||
|
||||
const parent = document.createElement('div')
|
||||
Object.defineProperty(parent, 'clientWidth', {
|
||||
value: 400,
|
||||
configurable: true
|
||||
})
|
||||
Object.defineProperty(parent, 'clientHeight', {
|
||||
value: 400,
|
||||
configurable: true
|
||||
})
|
||||
const canvas = document.createElement('canvas')
|
||||
parent.appendChild(canvas)
|
||||
|
||||
const setSize = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
view: { canvas, setSize },
|
||||
renderer: { domElement: canvas, setSize: vi.fn(), setPixelRatio },
|
||||
getZoomScaleCallback: undefined,
|
||||
targetWidth: 0,
|
||||
targetHeight: 0,
|
||||
@@ -526,7 +499,7 @@ describe('Load3d', () => {
|
||||
|
||||
ctx.load3d.handleResize()
|
||||
|
||||
expect(setSize).toHaveBeenCalledWith(400, 400)
|
||||
expect(setPixelRatio).toHaveBeenCalledWith(1)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -537,8 +510,7 @@ describe('Load3d', () => {
|
||||
const viewHelperRender = vi.fn()
|
||||
const controlsUpdate = vi.fn()
|
||||
const renderMainScene = vi.fn()
|
||||
const beginRender = vi.fn()
|
||||
const blit = vi.fn()
|
||||
const resetViewport = vi.fn()
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
STATUS_MOUSE_ON_NODE: true,
|
||||
@@ -553,16 +525,13 @@ describe('Load3d', () => {
|
||||
},
|
||||
viewHelperManager: {
|
||||
update: viewHelperUpdate,
|
||||
render: viewHelperRender
|
||||
viewHelper: { render: viewHelperRender }
|
||||
},
|
||||
controlsManager: { update: controlsUpdate },
|
||||
recordingManager: { getIsRecording: vi.fn(() => false) },
|
||||
renderMainScene,
|
||||
view: {
|
||||
beginRender,
|
||||
blit,
|
||||
renderer: { setScissorTest: vi.fn() }
|
||||
}
|
||||
resetViewport,
|
||||
renderer: {}
|
||||
})
|
||||
|
||||
;(ctx.load3d as unknown as { startAnimation(): void }).startAnimation()
|
||||
@@ -577,10 +546,9 @@ describe('Load3d', () => {
|
||||
expect(animationUpdate).toHaveBeenCalledOnce()
|
||||
expect(viewHelperUpdate).toHaveBeenCalledOnce()
|
||||
expect(controlsUpdate).toHaveBeenCalledOnce()
|
||||
expect(beginRender).toHaveBeenCalledOnce()
|
||||
expect(renderMainScene).toHaveBeenCalledOnce()
|
||||
expect(resetViewport).toHaveBeenCalledOnce()
|
||||
expect(viewHelperRender).toHaveBeenCalledOnce()
|
||||
expect(blit).toHaveBeenCalledOnce()
|
||||
|
||||
// Cancel the queued rAF so the test doesn't leak frames.
|
||||
loop.stop()
|
||||
@@ -592,10 +560,12 @@ describe('Load3d', () => {
|
||||
|
||||
Object.assign(ctx.load3d, {
|
||||
renderLoop: { stop },
|
||||
resizeObserver: null,
|
||||
contextMenuAbortController: null,
|
||||
view: {
|
||||
canvas,
|
||||
dispose: vi.fn()
|
||||
renderer: {
|
||||
forceContextLoss: vi.fn(),
|
||||
dispose: vi.fn(),
|
||||
domElement: canvas
|
||||
},
|
||||
sceneManager: { ...ctx.sceneManager, dispose: vi.fn() },
|
||||
cameraManager: { ...ctx.cameraManager, dispose: vi.fn() },
|
||||
|
||||
@@ -67,7 +67,7 @@ class Load3d extends Viewport3d {
|
||||
private hasLoadedModel: boolean = false
|
||||
|
||||
constructor(
|
||||
container: HTMLElement,
|
||||
container: Element | HTMLElement,
|
||||
deps: Load3dDeps,
|
||||
options: Load3DOptions = {}
|
||||
) {
|
||||
@@ -255,8 +255,8 @@ class Load3d extends Viewport3d {
|
||||
this.sceneManager.backgroundTexture &&
|
||||
this.sceneManager.backgroundMesh
|
||||
) {
|
||||
const containerWidth = this.domElement.clientWidth
|
||||
const containerHeight = this.domElement.clientHeight
|
||||
const containerWidth = this.renderer.domElement.clientWidth
|
||||
const containerHeight = this.renderer.domElement.clientHeight
|
||||
|
||||
if (this.shouldMaintainAspectRatio()) {
|
||||
const { width, height } = computeLetterboxedViewport(
|
||||
|
||||
@@ -108,7 +108,6 @@ describe('MeshModelAdapter', () => {
|
||||
expect(adapter.capabilities.exportable).toBe(true)
|
||||
expect([...adapter.capabilities.materialModes]).toEqual([
|
||||
'original',
|
||||
'clay',
|
||||
'normal',
|
||||
'wireframe'
|
||||
])
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user