镜像站点 · 本页由第三方 GitHub 只读镜像提供,非 GitHub 官方站点,不接受任何登录或凭据输入。前往 github.com
Skip to content

Commit 59e64f1

Browse files
committed
fix(ui): keep model viewer label and fallback reactive
1 parent fdc238a commit 59e64f1

3 files changed

Lines changed: 14 additions & 7 deletions

File tree

‎storage/framework/defaults/ai/skills/stacks-ui/SKILL.md‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,8 @@ state and do not install global click handlers.
106106
renders a reactive `ModelScene` from `@stacksjs/browser/model-viewer`, using native
107107
WebGL without a third-party 3D library. Camera presets, orbit, pinch/wheel zoom,
108108
keyboard input, responsive backing resolution and drawing fallback are built in.
109+
Bind `:label` and `:poster` when switching models; both react with the scene so
110+
the accessible canvas description and fallback drawing keep the selected context.
109111
See `stacks-browser` for units, geometry, source limits and cleanup contracts.
110112

111113
## Gotchas

‎storage/framework/defaults/resources/components/Media/ModelViewer.stx‎

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,13 @@
11
<script server>
2-
export const label = $props.label || 'Interactive 3D model'
3-
export const poster = $props.poster || ''
42
export const className = $props.className || ''
53
</script>
64
<script client>
75
import type { ModelScene, ModelViewerController } from '@stacksjs/browser/model-viewer'
86
import { createModelViewer } from '@stacksjs/browser/model-viewer'
97

108
const scene = useReactiveProp('scene', { nodes: [] } as ModelScene)
9+
const modelLabel = useReactiveProp('label', 'Interactive 3D model')
10+
const modelPoster = useReactiveProp('poster', '')
1111
const canvas = useRef('modelCanvas')
1212
const status = state('loading')
1313
const message = state('')
@@ -35,10 +35,10 @@ onDestroy(() => viewer?.dispose())
3535
</span>
3636
</div>
3737
<div class="stx-model-viewer-stage">
38-
<canvas ref="modelCanvas" class="stx-model-viewer-canvas" tabindex="0" role="img" aria-label="{{ label }}. Drag to rotate, scroll or pinch to zoom. Arrow keys rotate, plus and minus zoom, Home resets.">Interactive 3D requires a browser with WebGL. Use the drawing and downloads below.</canvas>
38+
<canvas ref="modelCanvas" class="stx-model-viewer-canvas" tabindex="0" role="img" :aria-label="modelLabel() + '. Drag to rotate, scroll or pinch to zoom. Arrow keys rotate, plus and minus zoom, Home resets.'">Interactive 3D requires a browser with WebGL. Use the drawing and downloads below.</canvas>
3939
<div class="stx-model-viewer-loading" :if="status() === 'loading'" role="status">Preparing the model…</div>
4040
<div class="stx-model-viewer-fallback" :if="status() === 'error'">
41-
@if (poster)<img src="{{ poster }}" alt="{{ label }} drawing" width="1400" height="1000">@endif
41+
<img :if="modelPoster()" :src="modelPoster()" :alt="modelLabel() + ' drawing'" width="1400" height="1000">
4242
<p role="status">{{ message() }}</p>
4343
</div>
4444
</div>

‎tests/unit/model-viewer-component.test.ts‎

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,14 +14,16 @@ test.skipIf(!findChrome())('native ModelViewer renders two isolated scenes, reac
1414
const source = `<!doctype html><html><body><script client>
1515
const scene = state({nodes:[{id:'first',geometry:{kind:'box',size:[10,20,30]},color:'#234b38'}]})
1616
const second = state({nodes:[{id:'second',geometry:{kind:'box',size:[20,10,10]}}]})
17+
const label = state('Second model')
18+
const poster = state('/poster.svg')
1719
function add(){scene.set({nodes:[...scene().nodes,{id:'added',geometry:{kind:'box',size:[5,5,5]},color:'#88948e'}]})}
18-
function fail(){second.set({nodes:[{id:'missing',geometry:{kind:'stl',src:'/missing.stl'}}]})}
19-
</script><div id="first"><ModelViewer :scene="scene()" label="First model" /></div><div id="second"><ModelViewer :scene="second()" label="Second model" poster="/poster.svg" /></div><button id="add" @click="add()">Add</button><button id="fail" @click="fail()">Fail</button></body></html>`
20+
function fail(){batch(()=>{label.set('Updated model');poster.set('/updated.svg');second.set({nodes:[{id:'missing',geometry:{kind:'stl',src:'/missing.stl'}}]})})}
21+
</script><div id="first"><ModelViewer :scene="scene()" label="First model" /></div><div id="second"><ModelViewer :scene="second()" :label="label()" :poster="poster()" /></div><button id="add" @click="add()">Add</button><button id="fail" @click="fail()">Fail</button></body></html>`
2022
const html = await processDirectives(source, {}, join(root, 'Page.stx'), { ...defaultConfig, root, componentsDir: resolve(import.meta.dir, '../../storage/framework/defaults/resources/components'), cache: false }, new Set())
2123
server = Bun.serve({ hostname: '127.0.0.1', port: 0, fetch(request) {
2224
const path = new URL(request.url).pathname
2325
if (path === '/missing.stl') return new Response('Missing', { status: 404 })
24-
if (path === '/poster.svg') return new Response('<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="20" height="20" fill="green"/></svg>', { headers: { 'Content-Type': 'image/svg+xml' } })
26+
if (path === '/poster.svg' || path === '/updated.svg') return new Response('<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20"><rect width="20" height="20" fill="green"/></svg>', { headers: { 'Content-Type': 'image/svg+xml' } })
2527
return new Response(html, { headers: { 'Content-Type': 'text/html' } })
2628
} })
2729
browser = await launch({ args: ['--use-angle=swiftshader', '--enable-unsafe-swiftshader'] })
@@ -40,6 +42,9 @@ function fail(){second.set({nodes:[{id:'missing',geometry:{kind:'stl',src:'/miss
4042
await waitFor(() => cdp.evaluate("document.querySelector('#second [data-model-status]').dataset.modelStatus === 'error'"))
4143
await settle(cdp)
4244
expect(await cdp.evaluate("document.querySelector('#second .stx-model-viewer-fallback').textContent")).toContain('could not be loaded')
45+
expect(await cdp.evaluate("document.querySelector('#second canvas').getAttribute('aria-label')")).toStartWith('Updated model.')
46+
expect(await cdp.evaluate("document.querySelector('#second .stx-model-viewer-fallback img').getAttribute('src')")).toBe('/updated.svg')
47+
expect(await cdp.evaluate("document.querySelector('#second .stx-model-viewer-fallback img').getAttribute('alt')")).toBe('Updated model drawing')
4348
expect(await cdp.evaluate("document.querySelector('#first [data-model-status]').dataset.modelStatus")).toBe('ready')
4449
expect(cdp.errors.filter(error => !error.includes('404 (Not Found)'))).toEqual([])
4550
}

0 commit comments

Comments
 (0)