feat(fc2a): add ImageCanvas (pan/zoom) and VideoCanvas (with format gate)
ImageCanvas mounts an <img> wrapped in the usePanZoom composable — spreading its handlers onto the container element. Zoom resets when src changes so prev/next nav doesn't carry zoom state into the next image. VideoCanvas plays MP4/WebM/Ogg/MOV natively. Non-playable formats render a friendly explainer with a download fallback, naming the actual MIME so operators know what they're dealing with, and pointing forward at FC-2e where transcoding lands. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,50 @@
|
|||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="fc-canvas"
|
||||||
|
:class="{ 'fc-canvas--zoomed': panZoom.state.scale > 1 }"
|
||||||
|
@wheel="panZoom.handlers.onWheel"
|
||||||
|
@pointerdown="panZoom.handlers.onPointerDown"
|
||||||
|
@pointermove="panZoom.handlers.onPointerMove"
|
||||||
|
@pointerup="panZoom.handlers.onPointerUp"
|
||||||
|
@click="panZoom.handlers.onClick"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="src" :alt="alt"
|
||||||
|
:style="{
|
||||||
|
transform: `translate(${panZoom.state.x}px, ${panZoom.state.y}px) scale(${panZoom.state.scale})`
|
||||||
|
}"
|
||||||
|
draggable="false"
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { watch } from 'vue'
|
||||||
|
import { usePanZoom } from '../../composables/usePanZoom.js'
|
||||||
|
|
||||||
|
const props = defineProps({ src: String, alt: String })
|
||||||
|
const panZoom = usePanZoom()
|
||||||
|
|
||||||
|
// Reset zoom when the src changes (prev/next nav).
|
||||||
|
watch(() => props.src, () => panZoom.reset())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-canvas {
|
||||||
|
flex: 1;
|
||||||
|
position: relative;
|
||||||
|
background: rgb(var(--v-theme-background));
|
||||||
|
overflow: hidden;
|
||||||
|
cursor: zoom-in;
|
||||||
|
user-select: none;
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.fc-canvas--zoomed { cursor: grab; }
|
||||||
|
.fc-canvas--zoomed:active { cursor: grabbing; }
|
||||||
|
.fc-canvas img {
|
||||||
|
max-width: 100%; max-height: 100%;
|
||||||
|
transform-origin: center;
|
||||||
|
transition: transform 100ms ease-out;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
<template>
|
||||||
|
<div class="fc-canvas fc-canvas--video">
|
||||||
|
<video
|
||||||
|
v-if="playable" :src="src" controls playsinline preload="metadata"
|
||||||
|
class="fc-canvas__video"
|
||||||
|
/>
|
||||||
|
<div v-else class="fc-canvas__unsupported">
|
||||||
|
<v-icon size="56" icon="mdi-video-off-outline" />
|
||||||
|
<h3 class="fc-canvas__unsupported-title">Format not browser-playable</h3>
|
||||||
|
<p>
|
||||||
|
This video isn't in a format the browser can play natively
|
||||||
|
(<code>{{ mime }}</code>). Transcoding to MP4 lands in FC-2e —
|
||||||
|
until then, you can download the original.
|
||||||
|
</p>
|
||||||
|
<v-btn :href="src" download variant="tonal" rounded="pill">
|
||||||
|
<v-icon start>mdi-download</v-icon>
|
||||||
|
Download original
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed } from 'vue'
|
||||||
|
|
||||||
|
const props = defineProps({ src: String, mime: String })
|
||||||
|
|
||||||
|
const BROWSER_PLAYABLE = new Set([
|
||||||
|
'video/mp4', 'video/webm', 'video/ogg', 'video/quicktime'
|
||||||
|
])
|
||||||
|
const playable = computed(() => BROWSER_PLAYABLE.has(props.mime))
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.fc-canvas {
|
||||||
|
flex: 1;
|
||||||
|
background: rgb(var(--v-theme-background));
|
||||||
|
display: flex; align-items: center; justify-content: center;
|
||||||
|
}
|
||||||
|
.fc-canvas__video { max-width: 100%; max-height: 100%; }
|
||||||
|
.fc-canvas__unsupported {
|
||||||
|
text-align: center;
|
||||||
|
max-width: 520px;
|
||||||
|
padding: 32px;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
.fc-canvas__unsupported-title {
|
||||||
|
font-family: 'Fraunces', Georgia, serif;
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 500;
|
||||||
|
margin: 12px 0 8px;
|
||||||
|
}
|
||||||
|
code {
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
padding: 1px 6px;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-family: 'JetBrains Mono', monospace;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
Reference in New Issue
Block a user