Compare commits
4
Commits
23a66c6fdf
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7093e1f6d2 | ||
|
|
9b314ad817 | ||
|
|
b9a4b53109 | ||
|
|
3a93d6ea5d |
No files matched your search
+15
-4
@@ -12,6 +12,7 @@ import {
|
||||
PhFloppyDisk,
|
||||
PhGearSix,
|
||||
PhImageSquare,
|
||||
PhMonitorPlay,
|
||||
PhPause,
|
||||
PhPencilSimpleLine,
|
||||
PhPlay,
|
||||
@@ -195,6 +196,11 @@ function stop() {
|
||||
store.pause()
|
||||
}
|
||||
|
||||
function openFullscreenPreview() {
|
||||
store.pause()
|
||||
fullscreenOpen.value = true
|
||||
}
|
||||
|
||||
async function saveProject() {
|
||||
// projectForSave stitches the current editor-session UI state
|
||||
// (zoom, pan, overlay toggles, timeline display) onto the
|
||||
@@ -508,8 +514,7 @@ function onKeyDown(e: KeyboardEvent) {
|
||||
if (isEditable(e.target)) return
|
||||
e.preventDefault()
|
||||
if (e.shiftKey) {
|
||||
store.pause()
|
||||
fullscreenOpen.value = true
|
||||
openFullscreenPreview()
|
||||
return
|
||||
}
|
||||
store.togglePlay()
|
||||
@@ -595,8 +600,7 @@ async function runDesktopMenuCommand(command: string) {
|
||||
rewind()
|
||||
break
|
||||
case 'fullscreen-preview':
|
||||
store.pause()
|
||||
fullscreenOpen.value = true
|
||||
openFullscreenPreview()
|
||||
break
|
||||
case 'toggle-panels':
|
||||
panelsHidden.value = !panelsHidden.value
|
||||
@@ -706,6 +710,13 @@ onBeforeUnmount(() => {
|
||||
@click="store.togglePlay()"
|
||||
/>
|
||||
<div class="transport-side transport-right">
|
||||
<IconButton
|
||||
:icon="PhMonitorPlay"
|
||||
icon-weight="regular"
|
||||
size="md"
|
||||
:title="t('app.fullscreenPlayback')"
|
||||
@click="openFullscreenPreview"
|
||||
/>
|
||||
<IconButton
|
||||
:icon="PhStop"
|
||||
icon-weight="fill"
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
setClipDragSource,
|
||||
} from '@/composables/useClipDragSource'
|
||||
import type { Asset } from '@/types/scene'
|
||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||
import { confirmDialog, promptDialog, ToggleSwitch } from '@/components/ui'
|
||||
import { loadSceneImages, renderButtonThumbnail, renderSvgThumbnail } from '@/lib/render'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
@@ -624,21 +624,11 @@ function onAssetDragStart(e: DragEvent, asset: ImageOrSvgAsset) {
|
||||
>
|
||||
<div class="assets-header">
|
||||
<h3>Assets</h3>
|
||||
<label
|
||||
class="thumbs-switch"
|
||||
<ToggleSwitch
|
||||
v-model="showThumbs"
|
||||
:label="t('sidebar.thumbsToggle')"
|
||||
v-tooltip="showThumbs ? t('sidebar.thumbnailsHide') : t('sidebar.thumbnailsShow')"
|
||||
>
|
||||
<span class="thumbs-switch-label">{{ t('sidebar.thumbsToggle') }}</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="thumbs-switch-input"
|
||||
:checked="showThumbs"
|
||||
@change="showThumbs = ($event.target as HTMLInputElement).checked"
|
||||
/>
|
||||
<span class="thumbs-switch-track">
|
||||
<span class="thumbs-switch-knob"></span>
|
||||
</span>
|
||||
</label>
|
||||
/>
|
||||
</div>
|
||||
|
||||
<button class="upload-btn" @click="triggerUpload">
|
||||
@@ -988,73 +978,6 @@ h4 {
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* Compact slide switch labelled "Thumbs" — native checkbox stays hidden
|
||||
* for a11y (keyboard + screen readers), the visible track + knob is
|
||||
* driven purely by the `:checked` state. */
|
||||
.thumbs-switch {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
font-size: 11px;
|
||||
color: var(--text-dim);
|
||||
}
|
||||
|
||||
.thumbs-switch-label {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.thumbs-switch-input {
|
||||
/* Keep focus behaviour via the native input; the visual sits next to it. */
|
||||
position: absolute;
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.thumbs-switch-track {
|
||||
position: relative;
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
transition: background 140ms var(--ease);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.thumbs-switch-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
transition: left 140ms var(--ease), background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.thumbs-switch-input:checked + .thumbs-switch-track {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.thumbs-switch-input:checked + .thumbs-switch-track .thumbs-switch-knob {
|
||||
left: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.thumbs-switch-input:focus-visible + .thumbs-switch-track {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.thumbs-switch:hover .thumbs-switch-track {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
|
||||
/* Section rows: unified layout — [caret + title][action?][count]. The
|
||||
* count always sits at the far right so the right column is visually
|
||||
* stable regardless of whether a row has a create-action (+) beside it. */
|
||||
|
||||
@@ -789,15 +789,45 @@ const cameraFrameConfig = computed(() => {
|
||||
height: frame.height,
|
||||
fillEnabled: false,
|
||||
stroke: selected ? '#38bdf8' : '#0ea5e9',
|
||||
strokeWidth: (selected ? 2.5 : 1.5) / zoom.value,
|
||||
strokeWidth: (selected ? 1.4 : 1) / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
dash: [10 / zoom.value, 6 / zoom.value],
|
||||
hitStrokeWidth: Math.max(10 / zoom.value, 8),
|
||||
dash: [4 / zoom.value, 3 / zoom.value],
|
||||
hitStrokeWidth: Math.max(8 / zoom.value, 6),
|
||||
listening: true,
|
||||
draggable: selected && !camera.locked,
|
||||
}
|
||||
})
|
||||
|
||||
const cameraResizeDragStart = ref<CameraProps | null>(null)
|
||||
|
||||
function cameraOutputAspect(): number {
|
||||
const canvas = store.scene.canvas
|
||||
const width = Number.isFinite(canvas.width) && canvas.width > 0 ? canvas.width : 1
|
||||
const height = Number.isFinite(canvas.height) && canvas.height > 0 ? canvas.height : 1
|
||||
return width / height
|
||||
}
|
||||
|
||||
const cameraResizeHandleConfig = computed(() => {
|
||||
const frame = cameraFrame.value
|
||||
const camera = store.scene.camera
|
||||
if (!frame || !camera || !store.selectedCamera || camera.locked) return null
|
||||
const size = 10 / zoom.value
|
||||
return {
|
||||
id: 'camera-resize-handle',
|
||||
name: 'camera-resize-handle',
|
||||
x: frame.x + frame.width - size / 2,
|
||||
y: frame.y + frame.height - size / 2,
|
||||
width: size,
|
||||
height: size,
|
||||
fill: '#f8fafc',
|
||||
stroke: '#38bdf8',
|
||||
strokeWidth: 1 / zoom.value,
|
||||
strokeScaleEnabled: false,
|
||||
draggable: true,
|
||||
listening: true,
|
||||
}
|
||||
})
|
||||
|
||||
function selectCameraFromStage(e?: Konva.KonvaEventObject<MouseEvent>) {
|
||||
if (e) e.cancelBubble = true
|
||||
store.selectCamera(true)
|
||||
@@ -817,6 +847,47 @@ function onCameraDragEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
onCameraDragMove(e)
|
||||
}
|
||||
|
||||
function onCameraResizeStart(e: Konva.KonvaEventObject<DragEvent | MouseEvent>) {
|
||||
e.cancelBubble = true
|
||||
if (store.scene.camera?.locked) return
|
||||
store.selectCamera(true)
|
||||
cameraResizeDragStart.value = cameraFrame.value ? { ...cameraFrame.value } : null
|
||||
}
|
||||
|
||||
function onCameraResizeDrag(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
e.cancelBubble = true
|
||||
const camera = store.scene.camera
|
||||
const start = cameraResizeDragStart.value ?? cameraFrame.value
|
||||
if (!camera || camera.locked || !start) return
|
||||
|
||||
const node = e.target
|
||||
const handleSize = cameraResizeHandleConfig.value?.width ?? 10 / zoom.value
|
||||
const aspect = cameraOutputAspect()
|
||||
const widthFromPointer = Math.max(1, node.x() + handleSize / 2 - start.x)
|
||||
const heightFromPointer = Math.max(1, node.y() + handleSize / 2 - start.y)
|
||||
const widthDelta = Math.abs(widthFromPointer - start.width) / Math.max(1, start.width)
|
||||
const heightDelta = Math.abs(heightFromPointer - start.height) / Math.max(1, start.height)
|
||||
const width = Math.max(
|
||||
1,
|
||||
Math.round(widthDelta >= heightDelta ? widthFromPointer : heightFromPointer * aspect),
|
||||
)
|
||||
const height = Math.max(1, Math.round(width / aspect))
|
||||
|
||||
node.x(start.x + width - handleSize / 2)
|
||||
node.y(start.y + height - handleSize / 2)
|
||||
store.upsertCameraKeyframe(store.playhead, {
|
||||
x: Math.round(start.x),
|
||||
y: Math.round(start.y),
|
||||
width,
|
||||
height,
|
||||
})
|
||||
}
|
||||
|
||||
function onCameraResizeEnd(e: Konva.KonvaEventObject<DragEvent>) {
|
||||
onCameraResizeDrag(e)
|
||||
cameraResizeDragStart.value = null
|
||||
}
|
||||
|
||||
function toggleCameraEnabled() {
|
||||
if (store.scene.camera?.enabled) {
|
||||
store.disableCamera()
|
||||
@@ -4793,6 +4864,14 @@ function onResizeHandlePointerUp(e: PointerEvent) {
|
||||
@dragmove="onCameraDragMove"
|
||||
@dragend="onCameraDragEnd"
|
||||
/>
|
||||
<v-rect
|
||||
v-if="cameraResizeHandleConfig"
|
||||
:config="cameraResizeHandleConfig"
|
||||
@mousedown="onCameraResizeStart"
|
||||
@dragstart="onCameraResizeStart"
|
||||
@dragmove="onCameraResizeDrag"
|
||||
@dragend="onCameraResizeEnd"
|
||||
/>
|
||||
<v-rect
|
||||
v-for="h in canvasResizeHandles"
|
||||
:key="`canvas-resize-${h.edge}`"
|
||||
|
||||
@@ -14,8 +14,9 @@ import type { Theme } from '@/composables/useTheme'
|
||||
import { useTooltipPref } from '@/composables/useTooltipPref'
|
||||
import { useAssetThumbsPref } from '@/composables/useAssetThumbsPref'
|
||||
import { useShowWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
|
||||
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
|
||||
import { showLocked } from '@/composables/useShowLocked'
|
||||
import { Modal } from '@/components/ui'
|
||||
import { Modal, ToggleSwitch } from '@/components/ui'
|
||||
import { setLocale, t, useI18n } from '@/i18n'
|
||||
import type { Locale } from '@/i18n'
|
||||
|
||||
@@ -27,6 +28,7 @@ const { theme, setTheme } = useTheme()
|
||||
const { enabled: tooltipsEnabled } = useTooltipPref()
|
||||
const { enabled: thumbsEnabled } = useAssetThumbsPref()
|
||||
const { enabled: welcomeOnStart } = useShowWelcomeOnStart()
|
||||
const { enabled: fullscreenAutoplay } = useFullscreenPreviewAutoplay()
|
||||
const { locale } = useI18n()
|
||||
|
||||
// Re-check the stored clipart folder whenever settings opens — covers the
|
||||
@@ -245,9 +247,10 @@ function applyTheme(t: Theme) {
|
||||
</div>
|
||||
|
||||
<label class="row-toggle">
|
||||
<span class="switch" :class="{ on: tooltipsEnabled }">
|
||||
<input type="checkbox" v-model="tooltipsEnabled" />
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
v-model="tooltipsEnabled"
|
||||
:aria-label="t('settings.tooltipsLabel')"
|
||||
/>
|
||||
<div class="row-toggle-text">
|
||||
<div class="row-toggle-label">{{ t('settings.tooltipsLabel') }}</div>
|
||||
<div class="row-toggle-desc">{{ t('settings.tooltipsDesc') }}</div>
|
||||
@@ -255,9 +258,10 @@ function applyTheme(t: Theme) {
|
||||
</label>
|
||||
|
||||
<label class="row-toggle">
|
||||
<span class="switch" :class="{ on: showLocked }">
|
||||
<input type="checkbox" v-model="showLocked" />
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
v-model="showLocked"
|
||||
:aria-label="t('settings.showLockedLabel')"
|
||||
/>
|
||||
<div class="row-toggle-text">
|
||||
<div class="row-toggle-label">{{ t('settings.showLockedLabel') }}</div>
|
||||
<div class="row-toggle-desc">{{ t('settings.showLockedDesc') }}</div>
|
||||
@@ -265,9 +269,10 @@ function applyTheme(t: Theme) {
|
||||
</label>
|
||||
|
||||
<label class="row-toggle">
|
||||
<span class="switch" :class="{ on: thumbsEnabled }">
|
||||
<input type="checkbox" v-model="thumbsEnabled" />
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
v-model="thumbsEnabled"
|
||||
:aria-label="t('settings.assetThumbsLabel')"
|
||||
/>
|
||||
<div class="row-toggle-text">
|
||||
<div class="row-toggle-label">{{ t('settings.assetThumbsLabel') }}</div>
|
||||
<div class="row-toggle-desc">{{ t('settings.assetThumbsDesc') }}</div>
|
||||
@@ -275,15 +280,27 @@ function applyTheme(t: Theme) {
|
||||
</label>
|
||||
|
||||
<label class="row-toggle">
|
||||
<span class="switch" :class="{ on: welcomeOnStart }">
|
||||
<input type="checkbox" v-model="welcomeOnStart" />
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
v-model="welcomeOnStart"
|
||||
:aria-label="t('settings.welcomeOnStartLabel')"
|
||||
/>
|
||||
<div class="row-toggle-text">
|
||||
<div class="row-toggle-label">{{ t('settings.welcomeOnStartLabel') }}</div>
|
||||
<div class="row-toggle-desc">{{ t('settings.welcomeOnStartDesc') }}</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<label class="row-toggle">
|
||||
<ToggleSwitch
|
||||
v-model="fullscreenAutoplay"
|
||||
:aria-label="t('settings.fullscreenAutoplayLabel')"
|
||||
/>
|
||||
<div class="row-toggle-text">
|
||||
<div class="row-toggle-label">{{ t('settings.fullscreenAutoplayLabel') }}</div>
|
||||
<div class="row-toggle-desc">{{ t('settings.fullscreenAutoplayDesc') }}</div>
|
||||
</div>
|
||||
</label>
|
||||
|
||||
<div class="setting-block">
|
||||
<div class="setting-heading">{{ t('settings.appearanceTitle') }}</div>
|
||||
<div class="setting-desc">{{ t('settings.themeHint') }}</div>
|
||||
@@ -550,10 +567,10 @@ input[type='number'] {
|
||||
letter-spacing: normal;
|
||||
}
|
||||
|
||||
/* Switch + two-line label. Defensive resets defeat the scoped
|
||||
/* Switch row + two-line label. Defensive resets defeat the scoped
|
||||
blanket `label { flex-direction: column; text-transform: uppercase;
|
||||
font-size: 10px; flex: 1 1 0; }` that form-row labels in this file
|
||||
depend on — without the reset that rule would stack the switch on
|
||||
depend on — without the reset that rule would stack the control on
|
||||
top of the text and uppercase everything inside. */
|
||||
.row-toggle {
|
||||
display: flex;
|
||||
@@ -599,47 +616,6 @@ input[type='number'] {
|
||||
text-transform: none;
|
||||
}
|
||||
|
||||
.row-toggle .switch {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
transition: background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.row-toggle .switch.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.row-toggle .switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.row-toggle .switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 140ms var(--ease);
|
||||
}
|
||||
|
||||
.row-toggle .switch.on::after {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
.clipart-row {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
@@ -14,7 +14,7 @@ import {
|
||||
import { useSceneStore } from '@/stores/scene'
|
||||
import { useProjectName } from '@/composables/useProjectName'
|
||||
import type { ButtonAsset, Layer } from '@/types/scene'
|
||||
import { Modal } from '@/components/ui'
|
||||
import { Modal, ToggleSwitch } from '@/components/ui'
|
||||
import { t } from '@/i18n'
|
||||
import { hasDesktopRender } from '@/lib/desktopRenderExport'
|
||||
|
||||
@@ -454,13 +454,12 @@ function onUpdate(v: boolean) {
|
||||
:class="{ disabled: !hasHiddenLayers }"
|
||||
v-tooltip="hasHiddenLayers ? undefined : t('export.skipHiddenNone')"
|
||||
>
|
||||
<span class="switch" :class="{ on: skipHidden && hasHiddenLayers }">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="skipHidden"
|
||||
:disabled="!hasHiddenLayers"
|
||||
/>
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
:model-value="skipHidden && hasHiddenLayers"
|
||||
:aria-label="t('export.skipHidden')"
|
||||
:disabled="!hasHiddenLayers"
|
||||
@update:model-value="skipHidden = $event"
|
||||
/>
|
||||
<div class="export-toggle-text">
|
||||
<div class="export-toggle-label">{{ t('export.skipHidden') }}</div>
|
||||
<div class="export-toggle-desc">{{ t('export.skipHiddenDesc') }}</div>
|
||||
@@ -784,50 +783,6 @@ function onUpdate(v: boolean) {
|
||||
line-height: 1.35;
|
||||
}
|
||||
|
||||
/* iOS-style switch. The <input> is a transparent overlay that still
|
||||
handles keyboard focus and click targeting; the visual is drawn by
|
||||
the span + ::after pill. */
|
||||
.switch {
|
||||
position: relative;
|
||||
width: 32px;
|
||||
height: 18px;
|
||||
flex-shrink: 0;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
transition: background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.switch.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.switch input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
margin: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
opacity: 0;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.switch::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 140ms var(--ease);
|
||||
}
|
||||
|
||||
.switch.on::after {
|
||||
transform: translateX(14px);
|
||||
}
|
||||
|
||||
.target-size {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
|
||||
@@ -6,8 +6,10 @@ import { cameraAspect, isCameraEnabled } from '@/lib/camera'
|
||||
import { mountPlayer, type PlayerHandle } from '@/lib/exportPlayer'
|
||||
import { t } from '@/i18n'
|
||||
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||
import { useFullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
|
||||
|
||||
const { formatTime } = useTimeFormat()
|
||||
const { enabled: autoplay } = useFullscreenPreviewAutoplay()
|
||||
|
||||
const props = defineProps<{ modelValue: boolean }>()
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
@@ -29,7 +31,8 @@ let player: PlayerHandle | null = null
|
||||
const aspect = computed(
|
||||
() => {
|
||||
if (isCameraEnabled(store.scene)) {
|
||||
const a = cameraAspect(store.scene, store.playhead)
|
||||
const time = props.modelValue ? localTime.value : store.playhead
|
||||
const a = cameraAspect(store.scene, time)
|
||||
return `${a} / 1`
|
||||
}
|
||||
return `${store.scene.canvas.width} / ${store.scene.canvas.height}`
|
||||
@@ -50,13 +53,14 @@ async function startPreview() {
|
||||
// stage. Otherwise the two runtimes fight — editor audio pool +
|
||||
// preview audio pool would both emit the same mp3s at once.
|
||||
store.pause()
|
||||
playing.value = true
|
||||
playing.value = autoplay.value
|
||||
showControls.value = false
|
||||
const start = rangeIn.value
|
||||
const end = rangeOut.value
|
||||
const head = store.playhead
|
||||
const initialTime = head < start || head >= end ? start : head
|
||||
mountAt(el, initialTime, true)
|
||||
localTime.value = initialTime
|
||||
mountAt(el, initialTime, autoplay.value)
|
||||
requestBrowserFullscreen()
|
||||
}
|
||||
|
||||
@@ -66,6 +70,7 @@ async function startPreview() {
|
||||
* player and rebuild at the same playhead so visual continuity stays
|
||||
* intact across the switch. */
|
||||
function mountAt(el: HTMLElement, time: number, wasPlaying: boolean) {
|
||||
localTime.value = time
|
||||
player = mountPlayer(el, store.project, {
|
||||
loop: true,
|
||||
onFrame(t: number, paused: boolean) {
|
||||
@@ -100,7 +105,7 @@ watch(
|
||||
if (!props.modelValue) return
|
||||
const still = frameEl.value
|
||||
if (!still) return
|
||||
mountAt(still, keepTime, true)
|
||||
mountAt(still, keepTime, playing.value)
|
||||
})
|
||||
},
|
||||
)
|
||||
|
||||
@@ -23,6 +23,7 @@ import type { Action, AnimatableProps, CameraProps, Layer } from '@/types/scene'
|
||||
import RangeSlider from './RangeSlider.vue'
|
||||
import ActionEditor from './ActionEditor.vue'
|
||||
import FontPicker from './FontPicker.vue'
|
||||
import { ToggleSwitch } from '@/components/ui'
|
||||
import { t } from '@/i18n'
|
||||
import { useTimeFormat } from '@/composables/useTimeFormat'
|
||||
import { useEyeDropper } from '@/composables/useEyeDropper'
|
||||
@@ -136,6 +137,16 @@ function setCameraProp(patch: Partial<CameraProps>) {
|
||||
store.upsertCameraKeyframe(store.playhead, patch)
|
||||
}
|
||||
|
||||
function cameraOutputAspect(): number {
|
||||
const width = Number.isFinite(store.scene.canvas.width) && store.scene.canvas.width > 0
|
||||
? store.scene.canvas.width
|
||||
: 1
|
||||
const height = Number.isFinite(store.scene.canvas.height) && store.scene.canvas.height > 0
|
||||
? store.scene.canvas.height
|
||||
: 1
|
||||
return width / height
|
||||
}
|
||||
|
||||
const cameraX = computed<number>({
|
||||
get: () => Math.round(sampledCamera.value?.x ?? 0),
|
||||
set: (v) => setCameraProp({ x: v }),
|
||||
@@ -146,11 +157,17 @@ const cameraY = computed<number>({
|
||||
})
|
||||
const cameraWidth = computed<number>({
|
||||
get: () => Math.round(sampledCamera.value?.width ?? store.scene.canvas.width),
|
||||
set: (v) => setCameraProp({ width: Math.max(1, v) }),
|
||||
set: (v) => {
|
||||
const width = Math.max(1, v)
|
||||
setCameraProp({ width, height: Math.max(1, Math.round(width / cameraOutputAspect())) })
|
||||
},
|
||||
})
|
||||
const cameraHeight = computed<number>({
|
||||
get: () => Math.round(sampledCamera.value?.height ?? store.scene.canvas.height),
|
||||
set: (v) => setCameraProp({ height: Math.max(1, v) }),
|
||||
set: (v) => {
|
||||
const height = Math.max(1, v)
|
||||
setCameraProp({ width: Math.max(1, Math.round(height * cameraOutputAspect())), height })
|
||||
},
|
||||
})
|
||||
|
||||
function removeCameraKeyframeHere() {
|
||||
@@ -669,33 +686,24 @@ function deleteSelection() {
|
||||
<div v-if="sectionOpen.transform" class="group-body">
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.camera.enabled') }}</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: cameraEnabled }"
|
||||
@click="cameraEnabled = !cameraEnabled"
|
||||
>
|
||||
{{ cameraEnabled ? 'On' : 'Off' }}
|
||||
</button>
|
||||
<ToggleSwitch
|
||||
v-model="cameraEnabled"
|
||||
:aria-label="t('properties.camera.enabled')"
|
||||
/>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.camera.overlay') }}</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: cameraOverlayVisible }"
|
||||
@click="cameraOverlayVisible = !cameraOverlayVisible"
|
||||
>
|
||||
{{ cameraOverlayVisible ? 'On' : 'Off' }}
|
||||
</button>
|
||||
<ToggleSwitch
|
||||
v-model="cameraOverlayVisible"
|
||||
:aria-label="t('properties.camera.overlay')"
|
||||
/>
|
||||
</div>
|
||||
<div class="toggle-row">
|
||||
<span class="toggle-label">{{ t('properties.camera.locked') }}</span>
|
||||
<button
|
||||
class="toggle-btn"
|
||||
:class="{ active: cameraLocked }"
|
||||
@click="cameraLocked = !cameraLocked"
|
||||
>
|
||||
{{ cameraLocked ? 'On' : 'Off' }}
|
||||
</button>
|
||||
<ToggleSwitch
|
||||
v-model="cameraLocked"
|
||||
:aria-label="t('properties.camera.locked')"
|
||||
/>
|
||||
</div>
|
||||
<div class="row">
|
||||
<label>X<input type="number" v-model.number="cameraX" :disabled="cameraLocked" /></label>
|
||||
|
||||
+143
-11
@@ -819,6 +819,11 @@ function onCameraLaneDblClick(e: MouseEvent) {
|
||||
|
||||
function onCameraKeyframeClick(e: MouseEvent, time: number) {
|
||||
e.stopPropagation()
|
||||
if (cameraKfDrag.value?.moved) {
|
||||
cameraKfDrag.value = null
|
||||
return
|
||||
}
|
||||
cameraKfDrag.value = null
|
||||
store.selectCamera(true)
|
||||
store.setPlayhead(time)
|
||||
}
|
||||
@@ -830,6 +835,94 @@ function onCameraKeyframeDblClick(e: MouseEvent, keyframeId: string) {
|
||||
store.removeCameraKeyframe(keyframeId)
|
||||
}
|
||||
|
||||
function onCameraKeyframeContextMenu(e: MouseEvent, keyframeId: string) {
|
||||
e.preventDefault()
|
||||
e.stopPropagation()
|
||||
const camera = store.scene.camera
|
||||
if (!camera || camera.locked) return
|
||||
const kf = camera.keyframes.find((item) => item.id === keyframeId)
|
||||
if (!kf) return
|
||||
const sorted = camera.keyframes.slice().sort((a, b) => a.time - b.time)
|
||||
menu.value = {
|
||||
target: 'camera',
|
||||
keyframeId: kf.id,
|
||||
time: kf.time,
|
||||
x: e.clientX,
|
||||
y: e.clientY,
|
||||
currentEasing: kf.easing,
|
||||
isFirst: sorted[0]?.id === kf.id,
|
||||
isLast: sorted[sorted.length - 1]?.id === kf.id,
|
||||
isHeld: !!kf.hold,
|
||||
isEnd: false,
|
||||
hasEnd: false,
|
||||
}
|
||||
store.selectCamera(true)
|
||||
}
|
||||
|
||||
interface CameraKfDrag {
|
||||
keyframeId: string
|
||||
lane: HTMLElement
|
||||
pointerId: number
|
||||
startX: number
|
||||
moved: boolean
|
||||
initialTime: number
|
||||
}
|
||||
|
||||
const cameraKfDrag = ref<CameraKfDrag | null>(null)
|
||||
|
||||
function onCameraKeyframePointerDown(e: PointerEvent, keyframeId: string) {
|
||||
if (e.button !== 0) return
|
||||
const camera = store.scene.camera
|
||||
if (!camera || camera.locked) return
|
||||
const kfEl = e.currentTarget as HTMLElement
|
||||
const lane = kfEl.parentElement
|
||||
const kf = camera.keyframes.find((item) => item.id === keyframeId)
|
||||
if (!lane || !kf) return
|
||||
|
||||
e.stopPropagation()
|
||||
kfEl.setPointerCapture(e.pointerId)
|
||||
store.selectCamera(true)
|
||||
cameraKfDrag.value = {
|
||||
keyframeId,
|
||||
lane,
|
||||
pointerId: e.pointerId,
|
||||
startX: e.clientX,
|
||||
moved: false,
|
||||
initialTime: kf.time,
|
||||
}
|
||||
}
|
||||
|
||||
function onCameraKeyframePointerMove(e: PointerEvent) {
|
||||
const d = cameraKfDrag.value
|
||||
if (!d || d.pointerId !== e.pointerId) return
|
||||
if (!d.moved && Math.abs(e.clientX - d.startX) < 3) return
|
||||
d.moved = true
|
||||
const rect = d.lane.getBoundingClientRect()
|
||||
if (rect.width <= 0) return
|
||||
const ratio = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width))
|
||||
const rawNewTime = ratio * duration.value
|
||||
const newTime = snapToCandidates(
|
||||
rawNewTime,
|
||||
[...allKeyframeTimesExcluding(new Set([d.keyframeId])), store.playhead],
|
||||
{
|
||||
enabled: snapEnabled.value,
|
||||
pxPerSec: effectivePxPerSec.value,
|
||||
pixelThreshold: SNAP_PX,
|
||||
duration: duration.value,
|
||||
},
|
||||
)
|
||||
store.setCameraKeyframeTime(d.keyframeId, newTime)
|
||||
store.setPlayhead(newTime)
|
||||
}
|
||||
|
||||
function onCameraKeyframePointerUp(e: PointerEvent) {
|
||||
const d = cameraKfDrag.value
|
||||
if (!d || d.pointerId !== e.pointerId) return
|
||||
const el = e.currentTarget as HTMLElement
|
||||
if (el.hasPointerCapture(e.pointerId)) el.releasePointerCapture(e.pointerId)
|
||||
if (!d.moved) cameraKfDrag.value = null
|
||||
}
|
||||
|
||||
function onKeyframeClick(e: MouseEvent, layerId: string, time: number) {
|
||||
e.stopPropagation()
|
||||
if (kfDrag.value?.moved) {
|
||||
@@ -1306,6 +1399,9 @@ function allKeyframeTimesExcluding(ignoreIds: Set<string>): number[] {
|
||||
if (layer.rotationKeyframes)
|
||||
for (const k of layer.rotationKeyframes) if (!ignoreIds.has(k.id)) out.push(k.time)
|
||||
}
|
||||
for (const k of store.scene.camera?.keyframes ?? []) {
|
||||
if (!ignoreIds.has(k.id)) out.push(k.time)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
@@ -1418,6 +1514,14 @@ interface KfSegment {
|
||||
tail?: boolean
|
||||
}
|
||||
|
||||
interface KfSegmentSource {
|
||||
id: string
|
||||
time: number
|
||||
easing: Easing
|
||||
hold?: boolean
|
||||
kind?: 'normal' | 'end'
|
||||
}
|
||||
|
||||
/** Render-time filter for the kf v-for. Hides end kfs that share a
|
||||
* time slot with a normal kf — common after the end-kf migration
|
||||
* on legacy projects, where stacking a square (end) on top of a
|
||||
@@ -1435,7 +1539,7 @@ function displayKfs(kfs: Keyframe[]): Keyframe[] {
|
||||
})
|
||||
}
|
||||
|
||||
function segmentsOf(kfs: Keyframe[], duration: number, buttonMode = false): KfSegment[] {
|
||||
function segmentsOf(kfs: KfSegmentSource[], duration: number, buttonMode = false): KfSegment[] {
|
||||
if (kfs.length === 0) return []
|
||||
// End kfs are visibility markers, not interpolation participants.
|
||||
// Pull them out for tail computation; everything else flows through
|
||||
@@ -1571,7 +1675,8 @@ const EASING_OPTIONS: { value: Easing; label: string }[] = [
|
||||
]
|
||||
|
||||
interface KfMenuState {
|
||||
layerId: string
|
||||
target: 'layer' | 'camera'
|
||||
layerId?: string
|
||||
keyframeId: string
|
||||
time: number
|
||||
x: number
|
||||
@@ -1610,6 +1715,7 @@ function onKeyframeContextMenu(
|
||||
.sort((a, b) => a.time - b.time)
|
||||
const hasEnd = layer.keyframes.some((k) => k.kind === 'end')
|
||||
menu.value = {
|
||||
target: 'layer',
|
||||
layerId,
|
||||
keyframeId: kf.id,
|
||||
time: kf.time,
|
||||
@@ -1627,7 +1733,11 @@ function onKeyframeContextMenu(
|
||||
|
||||
function toggleHold() {
|
||||
if (!menu.value) return
|
||||
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
|
||||
if (menu.value.target === 'camera') {
|
||||
store.setCameraKeyframeHold(menu.value.keyframeId, !menu.value.isHeld)
|
||||
} else if (menu.value.layerId) {
|
||||
store.setKeyframeHold(menu.value.layerId, menu.value.keyframeId, !menu.value.isHeld)
|
||||
}
|
||||
menu.value = null
|
||||
}
|
||||
|
||||
@@ -1639,7 +1749,7 @@ function toggleHold() {
|
||||
* we delete the existing one before promoting the new one. */
|
||||
function toggleEndKind() {
|
||||
const m = menu.value
|
||||
if (!m) return
|
||||
if (!m || m.target !== 'layer' || !m.layerId) return
|
||||
const layer = store.activeLayers.find((l) => l.id === m.layerId)
|
||||
if (!layer) return
|
||||
const kf = layer.keyframes.find((k) => k.id === m.keyframeId)
|
||||
@@ -1663,21 +1773,30 @@ function toggleEndKind() {
|
||||
|
||||
function applyEasing(easing: Easing) {
|
||||
if (!menu.value) return
|
||||
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
|
||||
if (menu.value.target === 'camera') {
|
||||
store.setCameraKeyframeEasing(menu.value.keyframeId, easing)
|
||||
} else if (menu.value.layerId) {
|
||||
store.setKeyframeEasing(menu.value.layerId, menu.value.keyframeId, easing)
|
||||
}
|
||||
menu.value = null
|
||||
}
|
||||
|
||||
function deleteFromMenu() {
|
||||
if (!menu.value) return
|
||||
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
|
||||
if (menu.value.target === 'camera') {
|
||||
store.removeCameraKeyframe(menu.value.keyframeId)
|
||||
} else if (menu.value.layerId) {
|
||||
store.removeKeyframe(menu.value.layerId, menu.value.keyframeId)
|
||||
}
|
||||
menu.value = null
|
||||
}
|
||||
|
||||
function duplicateFromMenu() {
|
||||
if (!menu.value) return
|
||||
const newId = store.duplicateKeyframe(menu.value.layerId, menu.value.keyframeId)
|
||||
if (!menu.value || menu.value.target !== 'layer' || !menu.value.layerId) return
|
||||
const layerId = menu.value.layerId
|
||||
const newId = store.duplicateKeyframe(layerId, menu.value.keyframeId)
|
||||
if (newId) {
|
||||
const layer = store.activeLayers.find((l) => l.id === menu.value!.layerId)
|
||||
const layer = store.activeLayers.find((l) => l.id === layerId)
|
||||
const all = layer
|
||||
? [
|
||||
...layer.keyframes,
|
||||
@@ -3629,6 +3748,13 @@ onBeforeUnmount(() => {
|
||||
@dblclick="onCameraLaneDblClick"
|
||||
>
|
||||
<span class="playhead" :style="{ left: pxFromTime(store.playhead) + 'px' }" />
|
||||
<span
|
||||
v-for="seg in segmentsOf(store.scene.camera?.keyframes ?? [], duration)"
|
||||
:key="`camera-${seg.id}`"
|
||||
class="kf-seg"
|
||||
:class="[seg.held ? 'held' : `easing-${seg.easing}`, { tail: seg.tail }]"
|
||||
:style="segmentStyle(seg)"
|
||||
/>
|
||||
<span
|
||||
v-for="kf in store.scene.camera?.keyframes ?? []"
|
||||
:key="kf.id"
|
||||
@@ -3636,8 +3762,13 @@ onBeforeUnmount(() => {
|
||||
:class="{ hold: kf.hold }"
|
||||
:style="{ left: pxFromTime(kf.time) + 'px' }"
|
||||
v-tooltip="t('timeline.keyframeTooltip', { time: formatKfTime(kf.time) })"
|
||||
@pointerdown="onCameraKeyframePointerDown($event, kf.id)"
|
||||
@pointermove="onCameraKeyframePointerMove"
|
||||
@pointerup="onCameraKeyframePointerUp"
|
||||
@pointercancel="onCameraKeyframePointerUp"
|
||||
@click="onCameraKeyframeClick($event, kf.time)"
|
||||
@dblclick="onCameraKeyframeDblClick($event, kf.id)"
|
||||
@contextmenu="onCameraKeyframeContextMenu($event, kf.id)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -4368,6 +4499,7 @@ onBeforeUnmount(() => {
|
||||
<div class="kf-menu-divider"></div>
|
||||
</template>
|
||||
<button
|
||||
v-if="menu.target === 'layer'"
|
||||
class="kf-menu-item"
|
||||
:class="{ active: menu.isEnd }"
|
||||
@click="toggleEndKind"
|
||||
@@ -4381,8 +4513,8 @@ onBeforeUnmount(() => {
|
||||
class="kf-menu-trailing-check"
|
||||
/>
|
||||
</button>
|
||||
<div class="kf-menu-divider"></div>
|
||||
<button v-if="!menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
|
||||
<div v-if="menu.target === 'layer'" class="kf-menu-divider"></div>
|
||||
<button v-if="menu.target === 'layer' && !menu.isEnd" class="kf-menu-item" @click="duplicateFromMenu">
|
||||
<PhCopy :size="12" class="kf-menu-icon" />
|
||||
{{ t('timeline.duplicate') }}
|
||||
</button>
|
||||
|
||||
@@ -21,7 +21,7 @@ import {
|
||||
pickBackupFile,
|
||||
readBackupFile,
|
||||
} from '@/lib/backupFile'
|
||||
import { confirmDialog, Modal } from '@/components/ui'
|
||||
import { confirmDialog, Modal, ToggleSwitch } from '@/components/ui'
|
||||
import { showWelcomeOnStart } from '@/composables/useShowWelcomeOnStart'
|
||||
import { t } from '@/i18n'
|
||||
|
||||
@@ -366,9 +366,10 @@ watch(
|
||||
<div v-if="backupError" class="backup-error">{{ backupError }}</div>
|
||||
<template #footer>
|
||||
<label class="ftr-toggle" v-tooltip="t('welcome.showOnStartHelp')">
|
||||
<span class="switch sm" :class="{ on: showWelcomeOnStart }">
|
||||
<input type="checkbox" v-model="showWelcomeOnStart" />
|
||||
</span>
|
||||
<ToggleSwitch
|
||||
v-model="showWelcomeOnStart"
|
||||
:aria-label="t('welcome.showOnStart')"
|
||||
/>
|
||||
<span class="ftr-toggle-label">{{ t('welcome.showOnStart') }}</span>
|
||||
</label>
|
||||
<span class="ftr-sep"></span>
|
||||
@@ -676,8 +677,6 @@ watch(
|
||||
border-radius: var(--radius-sm);
|
||||
}
|
||||
|
||||
/* Compact footer toggle — same visual language as the other toggles
|
||||
in the app but sized for the row. */
|
||||
.ftr-toggle {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -691,44 +690,4 @@ watch(
|
||||
.ftr-toggle-label {
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.switch.sm {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 28px;
|
||||
height: 16px;
|
||||
background: var(--border);
|
||||
border-radius: 999px;
|
||||
flex-shrink: 0;
|
||||
transition: background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.switch.sm input {
|
||||
opacity: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.switch.sm::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 12px;
|
||||
height: 12px;
|
||||
background: #fff;
|
||||
border-radius: 50%;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
transition: transform 140ms var(--ease);
|
||||
}
|
||||
|
||||
.switch.sm.on {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.switch.sm.on::after {
|
||||
transform: translateX(12px);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,110 @@
|
||||
<script setup lang="ts">
|
||||
defineProps<{
|
||||
modelValue: boolean
|
||||
label?: string
|
||||
ariaLabel?: string
|
||||
disabled?: boolean
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||
|
||||
function onChange(event: Event) {
|
||||
emit('update:modelValue', (event.target as HTMLInputElement).checked)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span class="ui-switch thumbs-switch" :class="{ disabled }">
|
||||
<span v-if="$slots.default || label" class="ui-switch-label thumbs-switch-label">
|
||||
<slot>{{ label }}</slot>
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
class="ui-switch-input thumbs-switch-input"
|
||||
:checked="modelValue"
|
||||
:disabled="disabled"
|
||||
:aria-label="ariaLabel || label"
|
||||
@change="onChange"
|
||||
/>
|
||||
<span class="ui-switch-track">
|
||||
<span class="ui-switch-knob"></span>
|
||||
</span>
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.ui-switch {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex: 0 0 auto;
|
||||
color: var(--text-dim);
|
||||
cursor: pointer;
|
||||
font-size: 11px;
|
||||
line-height: 1;
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
}
|
||||
|
||||
.ui-switch.disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ui-switch-label {
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.01em;
|
||||
}
|
||||
|
||||
.ui-switch-input {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
z-index: 2;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
opacity: 0;
|
||||
cursor: inherit;
|
||||
}
|
||||
|
||||
.ui-switch-track {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
width: 26px;
|
||||
height: 14px;
|
||||
border-radius: 999px;
|
||||
background: var(--border);
|
||||
transition: background 140ms var(--ease);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ui-switch-knob {
|
||||
position: absolute;
|
||||
top: 2px;
|
||||
left: 2px;
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border-radius: 50%;
|
||||
background: var(--panel);
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.35);
|
||||
transition: left 140ms var(--ease), background 140ms var(--ease);
|
||||
}
|
||||
|
||||
.ui-switch-input:checked + .ui-switch-track {
|
||||
background: var(--accent);
|
||||
}
|
||||
|
||||
.ui-switch-input:checked + .ui-switch-track .ui-switch-knob {
|
||||
left: 14px;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.ui-switch-input:focus-visible + .ui-switch-track {
|
||||
outline: 2px solid var(--accent);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
|
||||
.ui-switch:hover:not(.disabled) .ui-switch-track {
|
||||
filter: brightness(1.08);
|
||||
}
|
||||
</style>
|
||||
@@ -2,5 +2,6 @@ export { default as DialogsHost } from './DialogsHost.vue'
|
||||
export { default as IconButton } from './IconButton.vue'
|
||||
export { default as Modal } from './Modal.vue'
|
||||
export { default as Toast } from './Toast.vue'
|
||||
export { default as ToggleSwitch } from './ToggleSwitch.vue'
|
||||
export { confirmDialog, dialogState, promptDialog } from './dialogs'
|
||||
export type { ConfirmOptions, PromptOptions } from './dialogs'
|
||||
@@ -0,0 +1,27 @@
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
const STORAGE_KEY = 'animatr.fullscreenPreviewAutoplay'
|
||||
|
||||
function read(): boolean {
|
||||
try {
|
||||
return localStorage.getItem(STORAGE_KEY) === 'true'
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// Module-level shared ref so settings and fullscreen preview use the
|
||||
// same preference without prop drilling through the app shell.
|
||||
export const fullscreenPreviewAutoplay = ref<boolean>(read())
|
||||
|
||||
watch(fullscreenPreviewAutoplay, (v) => {
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, String(v))
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
})
|
||||
|
||||
export function useFullscreenPreviewAutoplay() {
|
||||
return { enabled: fullscreenPreviewAutoplay }
|
||||
}
|
||||
+416
-3
@@ -34,6 +34,7 @@ import { drawButtonFrame, drawSceneFrame, renderButtonThumbnail } from '@/lib/re
|
||||
import { t as i18nT } from '@/i18n'
|
||||
import { expandedTrackOrder } from '@/lib/sceneOrder'
|
||||
import { CLIP_FALLBACK_BOUNDS, computeClipBounds } from '@/lib/clipBounds'
|
||||
import { renderSceneFrameForOutput } from '@/lib/camera'
|
||||
import {
|
||||
deleteStoredProject,
|
||||
getStoredProject,
|
||||
@@ -43,6 +44,7 @@ import {
|
||||
import Modal from '@/components/ui/Modal.vue'
|
||||
import IconButton from '@/components/ui/IconButton.vue'
|
||||
import Toast from '@/components/ui/Toast.vue'
|
||||
import App from '@/App.vue'
|
||||
import PropertiesPanel from '@/components/PropertiesPanel.vue'
|
||||
import ClipartDrawer from '@/components/ClipartDrawer.vue'
|
||||
import SceneDrawer from '@/components/SceneDrawer.vue'
|
||||
@@ -55,6 +57,7 @@ import ExportDialog from '@/components/ExportDialog.vue'
|
||||
import WelcomeDialog from '@/components/WelcomeDialog.vue'
|
||||
import FullscreenPreview from '@/components/FullscreenPreview.vue'
|
||||
import HelpDialog from '@/components/HelpDialog.vue'
|
||||
import { fullscreenPreviewAutoplay } from '@/composables/useFullscreenPreviewAutoplay'
|
||||
import { createAudioRuntime } from '@/composables/useAudioRuntime'
|
||||
import {
|
||||
clearWaveformCache,
|
||||
@@ -1428,6 +1431,88 @@ export function registerAllTests(): void {
|
||||
expect(frame?.querySelector('canvas')).toBeTruthy()
|
||||
handle.unmount()
|
||||
})
|
||||
|
||||
test('fullscreen preview autoplay preference controls the initial transport state', async () => {
|
||||
makeFreshStore()
|
||||
const before = fullscreenPreviewAutoplay.value
|
||||
const primaryButton = () =>
|
||||
document.body.querySelector('.fs-toolbar .fs-btn') as HTMLButtonElement | null
|
||||
try {
|
||||
fullscreenPreviewAutoplay.value = false
|
||||
const pausedHandle = mountComponent(FullscreenPreview, { modelValue: true })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
expect(primaryButton()?.getAttribute('aria-label')).toBe('Play')
|
||||
pausedHandle.unmount()
|
||||
await nextTick()
|
||||
|
||||
fullscreenPreviewAutoplay.value = true
|
||||
const playingHandle = mountComponent(FullscreenPreview, { modelValue: true })
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
expect(primaryButton()?.getAttribute('aria-label')).toBe('Pause')
|
||||
playingHandle.unmount()
|
||||
} finally {
|
||||
fullscreenPreviewAutoplay.value = before
|
||||
}
|
||||
})
|
||||
|
||||
test('top transport fullscreen button opens the preview and pauses editor playback', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.play()
|
||||
expect(s.playing).toBe(true)
|
||||
const handle = mountComponent(App, {})
|
||||
try {
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
const btn = handle.host.querySelector(
|
||||
`[aria-label="${i18nT('app.fullscreenPlayback')}"]`,
|
||||
) as HTMLButtonElement | null
|
||||
expect(btn).toBeTruthy()
|
||||
btn!.click()
|
||||
await nextTick()
|
||||
await nextTick()
|
||||
expect(s.playing).toBe(false)
|
||||
expect(document.body.querySelector('.fs-root')).toBeTruthy()
|
||||
expect(document.body.querySelector('.fs-frame')).toBeTruthy()
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('camera output renders off-stage content over the scene background', () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 100
|
||||
s.scene.canvas.height = 100
|
||||
s.scene.canvas.background = '#123456'
|
||||
const rect = s.addAsset({
|
||||
kind: 'rect',
|
||||
name: 'Outside',
|
||||
width: 10,
|
||||
height: 10,
|
||||
fill: '#ff0000',
|
||||
})
|
||||
const layer = s.addLayer(rect.id)!
|
||||
s.upsertKeyframe(layer.id, 0, { x: -15, y: 50 })
|
||||
s.enableCamera()
|
||||
s.setCameraBaseProps({ x: -50, y: 0, width: 100, height: 100 })
|
||||
|
||||
const canvas = document.createElement('canvas')
|
||||
canvas.width = 100
|
||||
canvas.height = 100
|
||||
const ctx = canvas.getContext('2d')!
|
||||
renderSceneFrameForOutput(ctx, s.scene, 0, {}, { width: 100, height: 100 })
|
||||
|
||||
const outsideStageBg = ctx.getImageData(10, 50, 1, 1).data
|
||||
expect(Array.from(outsideStageBg.slice(0, 3))).toEqual([0x12, 0x34, 0x56])
|
||||
expect(outsideStageBg[3]).toBe(255)
|
||||
|
||||
const outsideStageLayer = ctx.getImageData(35, 50, 1, 1).data
|
||||
expect(outsideStageLayer[0]).toBeGreaterThan(200)
|
||||
expect(outsideStageLayer[1]).toBeLessThan(40)
|
||||
expect(outsideStageLayer[2]).toBeLessThan(40)
|
||||
expect(outsideStageLayer[3]).toBe(255)
|
||||
})
|
||||
})
|
||||
|
||||
suite('export — target size scaling', () => {
|
||||
@@ -10199,6 +10284,334 @@ export function registerAllTests(): void {
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — camera framing', () => {
|
||||
function resetPropertySectionPrefs() {
|
||||
try {
|
||||
localStorage.removeItem('animatr:propSections')
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
|
||||
function stubLaneRect(lane: HTMLElement, width = 1000): void {
|
||||
const fakeRect = {
|
||||
left: 0,
|
||||
top: 0,
|
||||
width,
|
||||
height: 20,
|
||||
right: width,
|
||||
bottom: 20,
|
||||
x: 0,
|
||||
y: 0,
|
||||
toJSON: () => ({}),
|
||||
} as DOMRect
|
||||
;(lane as unknown as { getBoundingClientRect: () => DOMRect }).getBoundingClientRect = () =>
|
||||
fakeRect
|
||||
}
|
||||
|
||||
async function mountCameraCanvasWithKonva() {
|
||||
const VueKonva = (await import('vue-konva')).default
|
||||
const host = document.createElement('div')
|
||||
host.setAttribute('data-test-host', '')
|
||||
host.style.width = '800px'
|
||||
host.style.height = '600px'
|
||||
document.body.appendChild(host)
|
||||
const app = createApp({ render() { return h(CanvasStage, {}) } })
|
||||
app.use(VueKonva)
|
||||
app.directive('tooltip', vTooltip)
|
||||
app.mount(host)
|
||||
return {
|
||||
host,
|
||||
app,
|
||||
unmount() {
|
||||
app.unmount()
|
||||
host.remove()
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
async function getCameraKonvaStage() {
|
||||
const Konva = (await import('konva')).default
|
||||
return Konva.stages[Konva.stages.length - 1]
|
||||
}
|
||||
|
||||
test('stage camera button enables camera without resizing the editor stage', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 640
|
||||
s.scene.canvas.height = 360
|
||||
const handle = mountComponent(CanvasStage, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const dims = handle.host.querySelector('.dims') as HTMLElement
|
||||
const btn = handle.host.querySelector('.zoom-controls .camera-btn') as HTMLButtonElement
|
||||
expect(dims).toBeTruthy()
|
||||
expect(btn).toBeTruthy()
|
||||
expect(dims.textContent?.trim()).toBe('640 × 360')
|
||||
expect(s.scene.camera).toBe(undefined)
|
||||
|
||||
btn.click()
|
||||
await nextTick()
|
||||
expect(s.scene.camera?.enabled).toBe(true)
|
||||
expect(s.selectedCamera).toBe(true)
|
||||
expect(btn.classList.contains('active')).toBe(true)
|
||||
expect(dims.textContent?.trim()).toBe('640 × 360')
|
||||
expect(s.scene.canvas.width).toBe(640)
|
||||
expect(s.scene.canvas.height).toBe(360)
|
||||
|
||||
btn.click()
|
||||
await nextTick()
|
||||
expect(s.scene.camera?.enabled).toBe(false)
|
||||
expect(dims.textContent?.trim()).toBe('640 × 360')
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('properties panel edits the selected camera frame, not scene canvas size', async () => {
|
||||
resetPropertySectionPrefs()
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 800
|
||||
s.scene.canvas.height = 450
|
||||
s.enableCamera()
|
||||
s.selectCamera(true)
|
||||
const handle = mountComponent(PropertiesPanel, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const banner = handle.host.querySelector('.multi-banner') as HTMLElement
|
||||
expect(banner.textContent).toContain(i18nT('properties.camera.banner'))
|
||||
const inputs = handle.host.querySelectorAll<HTMLInputElement>(
|
||||
'.prop-group input[type="number"]',
|
||||
)
|
||||
expect(inputs.length).toBe(4)
|
||||
const [xInput, yInput, widthInput, heightInput] = Array.from(inputs)
|
||||
expect(Number(widthInput.value)).toBe(800)
|
||||
expect(Number(heightInput.value)).toBe(450)
|
||||
|
||||
xInput.value = '24'
|
||||
xInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
widthInput.value = '320'
|
||||
widthInput.dispatchEvent(new Event('input', { bubbles: true }))
|
||||
await nextTick()
|
||||
|
||||
expect(s.scene.camera?.keyframes.length).toBe(1)
|
||||
expect(s.scene.camera?.keyframes[0].props.x).toBe(24)
|
||||
expect(s.scene.camera?.keyframes[0].props.width).toBe(320)
|
||||
expect(s.scene.camera?.keyframes[0].props.height).toBe(180)
|
||||
expect(s.scene.canvas.width).toBe(800)
|
||||
expect(s.scene.canvas.height).toBe(450)
|
||||
void yInput
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('stage camera frame has one lower-right aspect resize handle', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.scene.canvas.width = 800
|
||||
s.scene.canvas.height = 450
|
||||
s.editor.canvasZoom = 1
|
||||
s.enableCamera()
|
||||
s.selectCamera(true)
|
||||
const handle = await mountCameraCanvasWithKonva()
|
||||
try {
|
||||
await nextTick()
|
||||
await tick(60)
|
||||
const stage = await getCameraKonvaStage() as unknown as {
|
||||
find: (sel: string) => unknown[]
|
||||
findOne: (sel: string) => unknown
|
||||
}
|
||||
const frame = stage.findOne('#camera-frame') as {
|
||||
strokeWidth: () => number
|
||||
dash: () => number[]
|
||||
}
|
||||
expect(frame).toBeTruthy()
|
||||
expect(frame.strokeWidth()).toBeLessThan(2.5)
|
||||
const dash = frame.dash()
|
||||
expect(dash.length).toBe(2)
|
||||
expect(dash[0]).toBeLessThan(10)
|
||||
expect(dash[0] / dash[1]).toBeCloseTo(4 / 3, 4)
|
||||
|
||||
const handles = stage.find('.camera-resize-handle') as Array<{
|
||||
x: (v?: number) => number
|
||||
y: (v?: number) => number
|
||||
width: () => number
|
||||
height: () => number
|
||||
fire: (type: string, evt: unknown, bubble: boolean) => void
|
||||
}>
|
||||
expect(handles.length).toBe(1)
|
||||
const grip = handles[0]
|
||||
const half = grip.width() / 2
|
||||
expect(grip.height()).toBe(grip.width())
|
||||
|
||||
grip.fire('dragstart', { target: grip, type: 'dragstart' }, true)
|
||||
grip.x(400 - half)
|
||||
grip.y(225 - half)
|
||||
grip.fire('dragmove', { target: grip, type: 'dragmove' }, true)
|
||||
await nextTick()
|
||||
|
||||
const kf = s.scene.camera?.keyframes[0]
|
||||
expect(s.scene.camera?.keyframes.length).toBe(1)
|
||||
expect(kf?.props.x).toBe(0)
|
||||
expect(kf?.props.y).toBe(0)
|
||||
expect(kf?.props.width).toBe(400)
|
||||
expect(kf?.props.height).toBe(225)
|
||||
expect(s.scene.canvas.width).toBe(800)
|
||||
expect(s.scene.canvas.height).toBe(450)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
|
||||
test('timeline camera row selects, hides, locks, and adds camera keyframes', async () => {
|
||||
const s = makeFreshStore()
|
||||
s.setDuration(10)
|
||||
s.enableCamera()
|
||||
s.selectCamera(false)
|
||||
const handle = mountComponent(Timeline, {})
|
||||
try {
|
||||
await nextTick()
|
||||
const trackName = handle.host.querySelector('.camera-track .track-name') as HTMLElement
|
||||
const eye = handle.host.querySelector('.camera-track .eye-btn') as HTMLButtonElement
|
||||
const lock = handle.host.querySelector('.camera-track .lock-btn') as HTMLButtonElement
|
||||
const lane = handle.host.querySelector('.camera-track .camera-lane') as HTMLElement
|
||||
expect(trackName).toBeTruthy()
|
||||
expect(eye).toBeTruthy()
|
||||
expect(lock).toBeTruthy()
|
||||
expect(lane).toBeTruthy()
|
||||
|
||||
trackName.click()
|
||||
await nextTick()
|
||||
expect(s.selectedCamera).toBe(true)
|
||||
|
||||
eye.click()
|
||||
await nextTick()
|
||||
expect(s.scene.camera?.overlayVisible).toBe(false)
|
||||
expect(eye.classList.contains('hidden')).toBe(true)
|
||||
|
||||
lock.click()
|
||||
await nextTick()
|
||||
expect(s.scene.camera?.locked).toBe(true)
|
||||
expect(lock.classList.contains('locked')).toBe(true)
|
||||
|
||||
stubLaneRect(lane)
|
||||
lane.dispatchEvent(
|
||||
new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 500 }),
|
||||
)
|
||||
await nextTick()
|
||||
expect(s.scene.camera?.keyframes.length).toBe(0)
|
||||
|
||||
lock.click()
|
||||
await nextTick()
|
||||
lane.dispatchEvent(
|
||||
new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 500 }),
|
||||
)
|
||||
await nextTick()
|
||||
expect(s.scene.camera?.keyframes.length).toBe(1)
|
||||
expect(Math.abs((s.scene.camera?.keyframes[0].time ?? 0) - 5) < 0.1).toBe(true)
|
||||
expect(Math.abs(s.playhead - 5) < 0.1).toBe(true)
|
||||
|
||||
await nextTick()
|
||||
const cameraKf = handle.host.querySelector('.camera-track .camera-kf') as HTMLElement
|
||||
expect(cameraKf).toBeTruthy()
|
||||
const captured = new Set<number>()
|
||||
;(cameraKf as unknown as { setPointerCapture: (id: number) => void }).setPointerCapture = (id) => {
|
||||
captured.add(id)
|
||||
}
|
||||
;(cameraKf as unknown as { releasePointerCapture: (id: number) => void }).releasePointerCapture = (id) => {
|
||||
captured.delete(id)
|
||||
}
|
||||
;(cameraKf as unknown as { hasPointerCapture: (id: number) => boolean }).hasPointerCapture = (id) =>
|
||||
captured.has(id)
|
||||
|
||||
cameraKf.dispatchEvent(
|
||||
new PointerEvent('pointerdown', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
button: 0,
|
||||
pointerId: 1,
|
||||
clientX: 500,
|
||||
}),
|
||||
)
|
||||
cameraKf.dispatchEvent(
|
||||
new PointerEvent('pointermove', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 1,
|
||||
clientX: 700,
|
||||
}),
|
||||
)
|
||||
cameraKf.dispatchEvent(
|
||||
new PointerEvent('pointerup', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
pointerId: 1,
|
||||
clientX: 700,
|
||||
}),
|
||||
)
|
||||
cameraKf.click()
|
||||
await nextTick()
|
||||
|
||||
expect(s.scene.camera?.keyframes.length).toBe(1)
|
||||
expect(Math.abs((s.scene.camera?.keyframes[0].time ?? 0) - 7) < 0.1).toBe(true)
|
||||
expect(Math.abs(s.playhead - 7) < 0.1).toBe(true)
|
||||
|
||||
lane.dispatchEvent(
|
||||
new MouseEvent('dblclick', { bubbles: true, cancelable: true, clientX: 900 }),
|
||||
)
|
||||
await nextTick()
|
||||
expect(s.scene.camera?.keyframes.length).toBe(2)
|
||||
const secondCameraKf = s.scene.camera?.keyframes.find((kf) => Math.abs(kf.time - 9) < 0.1)
|
||||
expect(secondCameraKf).toBeTruthy()
|
||||
|
||||
const cameraKfs = Array.from(
|
||||
handle.host.querySelectorAll<HTMLElement>('.camera-track .camera-kf'),
|
||||
)
|
||||
const secondKfEl = cameraKfs[cameraKfs.length - 1]
|
||||
secondKfEl.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
}),
|
||||
)
|
||||
await nextTick()
|
||||
let menu = handle.host.querySelector('.kf-menu') as HTMLElement
|
||||
expect(menu).toBeTruthy()
|
||||
const easeIn = Array.from(menu.querySelectorAll<HTMLButtonElement>('.kf-menu-item')).find(
|
||||
(button) => button.textContent?.includes(i18nT('timeline.easingEaseIn')),
|
||||
)
|
||||
expect(easeIn).toBeTruthy()
|
||||
expect(easeIn?.disabled).toBe(false)
|
||||
easeIn!.click()
|
||||
await nextTick()
|
||||
expect(secondCameraKf?.easing).toBe('easeIn')
|
||||
expect(handle.host.querySelector('.camera-track .kf-seg.easing-easeIn')).toBeTruthy()
|
||||
|
||||
secondKfEl.dispatchEvent(
|
||||
new MouseEvent('contextmenu', {
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
clientX: 320,
|
||||
clientY: 180,
|
||||
}),
|
||||
)
|
||||
await nextTick()
|
||||
menu = handle.host.querySelector('.kf-menu') as HTMLElement
|
||||
const deleteButton = Array.from(menu.querySelectorAll<HTMLButtonElement>('.kf-menu-item')).find(
|
||||
(button) => button.textContent?.includes(i18nT('timeline.delete')),
|
||||
)
|
||||
expect(deleteButton).toBeTruthy()
|
||||
deleteButton!.click()
|
||||
await nextTick()
|
||||
expect(s.scene.camera?.keyframes.length).toBe(1)
|
||||
expect(s.scene.camera?.keyframes.some((kf) => kf.id === secondCameraKf?.id)).toBe(false)
|
||||
} finally {
|
||||
handle.unmount()
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
suite('ui — CanvasStage centre crosshair (Konva)', () => {
|
||||
// Verify the lines actually render at the correct coordinates in
|
||||
// Konva when the toggle is on. Requires a proper VueKonva mount.
|
||||
@@ -15569,7 +15982,7 @@ export function registerAllTests(): void {
|
||||
expect(src).toContain('clipKaleidoPivot')
|
||||
expect(src).toContain('clipArmLocalT')
|
||||
// Runtime version got bumped during the kaleido + wiggle work.
|
||||
expect(src).toContain("'35'")
|
||||
expect(src).toContain("'36'")
|
||||
})
|
||||
|
||||
test('exported runtime layerActiveAt is unaffected by kaleido fields', async () => {
|
||||
@@ -15947,7 +16360,7 @@ export function registerAllTests(): void {
|
||||
expect(src).toContain('function wiggleSeeds(')
|
||||
// Runtime version got bumped through the wiggle work and the
|
||||
// later layer-kaleido-pivot landing.
|
||||
expect(src).toContain("'35'")
|
||||
expect(src).toContain("'36'")
|
||||
})
|
||||
})
|
||||
|
||||
@@ -16479,7 +16892,7 @@ export function registerAllTests(): void {
|
||||
const src = generateRuntimeScript()
|
||||
expect(src).toContain('function layerArmTf(')
|
||||
// Bumped on this work.
|
||||
expect(src).toContain("'35'")
|
||||
expect(src).toContain("'36'")
|
||||
})
|
||||
})
|
||||
}
|
||||
@@ -6,6 +6,7 @@ export const en = {
|
||||
pause: 'Pause (Space)',
|
||||
play: 'Play (Space)',
|
||||
stop: 'Stop',
|
||||
fullscreenPlayback: 'Fullscreen playback (Shift+Space)',
|
||||
recentProjects: 'Recent projects',
|
||||
copyStill: {
|
||||
label: 'Copy current frame',
|
||||
@@ -467,6 +468,9 @@ export const en = {
|
||||
welcomeOnStartLabel: 'Show welcome screen on start',
|
||||
welcomeOnStartDesc:
|
||||
'Open the welcome / recents dialog when RUWIO launches. Off → boot straight into the last edited project. The last file is always restored either way; the welcome dialog just gives you a chance to switch.',
|
||||
fullscreenAutoplayLabel: 'Autoplay in fullscreen preview',
|
||||
fullscreenAutoplayDesc:
|
||||
'Start playback immediately when fullscreen preview opens. When off, fullscreen opens paused and Space starts it.',
|
||||
bgVideoLabel: 'Video',
|
||||
bgVideoNone: 'No background video',
|
||||
bgVideoPickFile: 'Pick video file…',
|
||||
@@ -1140,6 +1144,7 @@ export const de: DeepPartial<Messages> = {
|
||||
pause: 'Pause (Leertaste)',
|
||||
play: 'Abspielen (Leertaste)',
|
||||
stop: 'Stop',
|
||||
fullscreenPlayback: 'Fullscreen-Wiedergabe (Shift+Leertaste)',
|
||||
recentProjects: 'Letzte Projekte',
|
||||
copyStill: {
|
||||
label: 'Aktuellen Frame kopieren',
|
||||
@@ -1334,6 +1339,12 @@ export const de: DeepPartial<Messages> = {
|
||||
assetThumbsLabel: 'Asset-Vorschaubilder',
|
||||
assetThumbsDesc:
|
||||
'Zeigt kleine Vorschauen in Assets, Clips und Buttons. Aus ist kompakter, an ist übersichtlicher.',
|
||||
welcomeOnStartLabel: 'Willkommensfenster beim Start anzeigen',
|
||||
welcomeOnStartDesc:
|
||||
'Öffnet beim Start von RUWIO den Willkommen-/Letzte-Projekte-Dialog. Aus startet direkt mit dem zuletzt bearbeiteten Projekt.',
|
||||
fullscreenAutoplayLabel: 'Autoplay in Fullscreen-Vorschau',
|
||||
fullscreenAutoplayDesc:
|
||||
'Startet die Wiedergabe sofort, wenn die Fullscreen-Vorschau geöffnet wird. Wenn aus, öffnet sie pausiert und Leertaste startet sie.',
|
||||
bgVideoLabel: 'Video',
|
||||
bgVideoNone: 'Kein Hintergrundvideo',
|
||||
bgVideoPickFile: 'Videodatei wählen…',
|
||||
|
||||
+10
-1
@@ -182,11 +182,20 @@ export function renderSceneFrameForOutput(
|
||||
ctx.save()
|
||||
ctx.setTransform(1, 0, 0, 1, 0, 0)
|
||||
ctx.clearRect(0, 0, outputWidth, outputHeight)
|
||||
const bg = scene.canvas.background
|
||||
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
|
||||
ctx.fillStyle = bg
|
||||
ctx.fillRect(0, 0, outputWidth, outputHeight)
|
||||
}
|
||||
ctx.restore()
|
||||
|
||||
ctx.save()
|
||||
ctx.scale(outputWidth / camera.width, outputHeight / camera.height)
|
||||
ctx.translate(-camera.x, -camera.y)
|
||||
drawSceneFrame(ctx, scene, time, images, { crop: options?.cropStage })
|
||||
drawSceneFrame(ctx, scene, time, images, {
|
||||
crop: options?.cropStage ?? false,
|
||||
clear: false,
|
||||
background: false,
|
||||
})
|
||||
ctx.restore()
|
||||
}
|
||||
@@ -11,7 +11,7 @@ function isProject(p: ExportPayload): p is Project {
|
||||
return Array.isArray((p as Partial<Project>).scenes)
|
||||
}
|
||||
|
||||
const RUNTIME_VERSION = '35'
|
||||
const RUNTIME_VERSION = '36'
|
||||
|
||||
/** Materialize the engine source. The font map is rebuilt on every
|
||||
* call so user-uploaded custom fonts (which live in localStorage
|
||||
@@ -1178,10 +1178,16 @@ const ENGINE_TEMPLATE = `(function(){
|
||||
ctx.beginPath();
|
||||
ctx.rect(0,0,vw,vh);
|
||||
ctx.clip();
|
||||
if(useCamera && !scene.backgroundVideo && scene.canvas.background && scene.canvas.background !== 'transparent'){
|
||||
ctx.fillStyle=scene.canvas.background;
|
||||
ctx.fillRect(0,0,vw,vh);
|
||||
}
|
||||
if(useCamera) ctx.translate(-cam.x,-cam.y);
|
||||
if(!scene.backgroundVideo){
|
||||
ctx.fillStyle=scene.canvas.background;
|
||||
ctx.fillRect(0,0,lw,lh);
|
||||
if(!useCamera){
|
||||
ctx.fillStyle=scene.canvas.background;
|
||||
ctx.fillRect(0,0,lw,lh);
|
||||
}
|
||||
}
|
||||
var layerById={};
|
||||
for(var li=0;li<scene.layers.length;li++)layerById[scene.layers[li].id]=scene.layers[li];
|
||||
|
||||
+10
-3
@@ -566,11 +566,18 @@ export function drawSceneFrame(
|
||||
scene: Scene,
|
||||
time: number,
|
||||
images: Record<string, HTMLImageElement>,
|
||||
options?: { crop?: boolean; buttonStates?: Map<string, ButtonState> },
|
||||
options?: {
|
||||
crop?: boolean
|
||||
clear?: boolean
|
||||
background?: boolean
|
||||
buttonStates?: Map<string, ButtonState>
|
||||
},
|
||||
) {
|
||||
const lw = scene.canvas.width
|
||||
const lh = scene.canvas.height
|
||||
const crop = options?.crop !== false
|
||||
const clear = options?.clear !== false
|
||||
const paintBackground = options?.background !== false
|
||||
const buttonStates = options?.buttonStates
|
||||
|
||||
ctx.save()
|
||||
@@ -583,13 +590,13 @@ export function drawSceneFrame(
|
||||
// Mandatory for transparent-background scenes so subsequent frames
|
||||
// don't accumulate alpha; harmless for opaque ones since `fillRect`
|
||||
// below paints over everything.
|
||||
ctx.clearRect(0, 0, lw, lh)
|
||||
if (clear) ctx.clearRect(0, 0, lw, lh)
|
||||
// A background video takes over the backdrop — leave the stage area
|
||||
// transparent so the DOM video underneath is visible. Same treatment
|
||||
// when the canvas background is explicitly 'transparent' so
|
||||
// APNG/WebM exports carry the alpha channel out to the file.
|
||||
const bg = scene.canvas.background
|
||||
if (!scene.backgroundVideo && bg && bg !== 'transparent') {
|
||||
if (paintBackground && !scene.backgroundVideo && bg && bg !== 'transparent') {
|
||||
ctx.fillStyle = bg
|
||||
ctx.fillRect(0, 0, lw, lh)
|
||||
}
|
||||
|
||||
Reference in new issue
Block a user