0.5.2: paste images, shared import helper, clipart persistence
- Clipboard paste creates an image layer: a document-level paste
listener detects image items in the clipboard (Strg+V after a
screenshot, e.g.) and imports them via the shared helper. Skipped
when focus is in a real text input / textarea / contenteditable.
- Shared importImageFile() helper in fileLoader.ts replaces the
duplicated image-import logic in AssetSidebar and CanvasStage's
clipart-drop path. Optional position argument for drop-at-point.
- Clipart folder persistence hardened for reloads:
- initialize() is now safely re-runnable (no-ops only once a handle
is actually loaded, tolerates refresh failures so the folder name
survives even if permission isn't auto-granted)
- App.vue's onMounted kicks off initialize() so it runs regardless
of whether the drawer component has mounted
- DocumentSettings re-invokes initialize() each time the modal
opens — covers app-start races and gives the user gesture needed
for FS Access permission prompts
- Opening the drawer refreshes thumbnails when handle is set but
items are empty, so the user-gesture context restores permission
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
1 parent
cbc309bc8c
commit
07b0ef2659
8 files changed
+124
-63
No files matched your search
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "animatr",
|
"name": "animatr",
|
||||||
"private": true,
|
"private": true,
|
||||||
"version": "0.5.1",
|
"version": "0.5.2",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
|
|||||||
+38
@@ -44,6 +44,8 @@ import {
|
|||||||
setLastOpenedId,
|
setLastOpenedId,
|
||||||
type StoredProject,
|
type StoredProject,
|
||||||
} from './lib/projectStore'
|
} from './lib/projectStore'
|
||||||
|
import { importImageFile } from './lib/fileLoader'
|
||||||
|
import { useClipartStore } from './stores/clipart'
|
||||||
import { useSceneStore } from './stores/scene'
|
import { useSceneStore } from './stores/scene'
|
||||||
|
|
||||||
const settingsOpen = ref(false)
|
const settingsOpen = ref(false)
|
||||||
@@ -131,6 +133,7 @@ const currentProjectName = ref<string | null>(null)
|
|||||||
|
|
||||||
usePlaybackLoop()
|
usePlaybackLoop()
|
||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
|
const clipart = useClipartStore()
|
||||||
const history = useHistory()
|
const history = useHistory()
|
||||||
|
|
||||||
function rewind() {
|
function rewind() {
|
||||||
@@ -278,15 +281,50 @@ function onKeyDown(e: KeyboardEvent) {
|
|||||||
store.togglePlay()
|
store.togglePlay()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isTextInputTarget(target: EventTarget | null): boolean {
|
||||||
|
if (!(target instanceof HTMLElement)) return false
|
||||||
|
if (target.isContentEditable) return true
|
||||||
|
const tag = target.tagName
|
||||||
|
if (tag === 'INPUT') {
|
||||||
|
const t = (target as HTMLInputElement).type
|
||||||
|
// Only block typed text inputs — color / range / checkbox shouldn't swallow paste
|
||||||
|
return ['text', 'search', 'email', 'number', 'tel', 'url', 'password'].includes(t)
|
||||||
|
}
|
||||||
|
if (tag === 'TEXTAREA') return true
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onPaste(e: ClipboardEvent) {
|
||||||
|
if (isTextInputTarget(e.target)) return
|
||||||
|
const data = e.clipboardData
|
||||||
|
if (!data) return
|
||||||
|
const items = Array.from(data.items)
|
||||||
|
const imageItems = items.filter((it) => it.kind === 'file' && it.type.startsWith('image/'))
|
||||||
|
if (imageItems.length === 0) return
|
||||||
|
e.preventDefault()
|
||||||
|
for (const it of imageItems) {
|
||||||
|
const file = it.getAsFile()
|
||||||
|
if (!file) continue
|
||||||
|
try {
|
||||||
|
await importImageFile(store, file)
|
||||||
|
} catch {
|
||||||
|
/* ignore invalid paste payloads */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
document.addEventListener('keydown', onKeyDown)
|
document.addEventListener('keydown', onKeyDown)
|
||||||
document.addEventListener('contextmenu', onDocumentContextMenu)
|
document.addEventListener('contextmenu', onDocumentContextMenu)
|
||||||
|
document.addEventListener('paste', onPaste)
|
||||||
window.addEventListener('resize', onWindowResize)
|
window.addEventListener('resize', onWindowResize)
|
||||||
|
void clipart.initialize()
|
||||||
bootWelcome()
|
bootWelcome()
|
||||||
})
|
})
|
||||||
onBeforeUnmount(() => {
|
onBeforeUnmount(() => {
|
||||||
document.removeEventListener('keydown', onKeyDown)
|
document.removeEventListener('keydown', onKeyDown)
|
||||||
document.removeEventListener('contextmenu', onDocumentContextMenu)
|
document.removeEventListener('contextmenu', onDocumentContextMenu)
|
||||||
|
document.removeEventListener('paste', onPaste)
|
||||||
window.removeEventListener('resize', onWindowResize)
|
window.removeEventListener('resize', onWindowResize)
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import {
|
|||||||
PhUploadSimple,
|
PhUploadSimple,
|
||||||
} from '@phosphor-icons/vue'
|
} from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader'
|
import { importImageFile } from '@/lib/fileLoader'
|
||||||
import type { Asset } from '@/types/scene'
|
import type { Asset } from '@/types/scene'
|
||||||
import { confirmDialog, promptDialog } from '@/components/ui'
|
import { confirmDialog, promptDialog } from '@/components/ui'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
@@ -161,34 +161,7 @@ async function handleFileSelected(event: Event) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importFile(file: File) {
|
async function importFile(file: File) {
|
||||||
const dataUrl = await readAsDataUrl(file)
|
await importImageFile(store, file)
|
||||||
|
|
||||||
let width = 0
|
|
||||||
let height = 0
|
|
||||||
|
|
||||||
if (isSvgFile(file)) {
|
|
||||||
const text = await file.text()
|
|
||||||
const dims = parseSvgDimensions(text)
|
|
||||||
if (dims) {
|
|
||||||
width = dims.width
|
|
||||||
height = dims.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!width || !height) {
|
|
||||||
const img = await loadImage(dataUrl)
|
|
||||||
width = img.naturalWidth || 300
|
|
||||||
height = img.naturalHeight || 300
|
|
||||||
}
|
|
||||||
|
|
||||||
const asset = store.addAsset({
|
|
||||||
kind: 'image',
|
|
||||||
name: file.name,
|
|
||||||
src: dataUrl,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
store.addLayer(asset.id)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function triggerUpload() {
|
function triggerUpload() {
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import type Konva from 'konva'
|
|||||||
import { PhCornersIn, PhCrop, PhMaskHappy, PhPencilSimple } from '@phosphor-icons/vue'
|
import { PhCornersIn, PhCrop, PhMaskHappy, PhPencilSimple } from '@phosphor-icons/vue'
|
||||||
import { useSceneStore } from '@/stores/scene'
|
import { useSceneStore } from '@/stores/scene'
|
||||||
import { sampleLayer } from '@/lib/interpolate'
|
import { sampleLayer } from '@/lib/interpolate'
|
||||||
import { isSvgFile, loadImage, parseSvgDimensions, readAsDataUrl } from '@/lib/fileLoader'
|
import { importImageFile, loadImage } from '@/lib/fileLoader'
|
||||||
import { useClipartStore } from '@/stores/clipart'
|
import { useClipartStore } from '@/stores/clipart'
|
||||||
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
|
import { drawSceneFrame, loadSceneImages } from '@/lib/render'
|
||||||
import type { AnimatableProps, Asset, Layer, Point } from '@/types/scene'
|
import type { AnimatableProps, Asset, Layer, Point } from '@/types/scene'
|
||||||
@@ -1834,33 +1834,7 @@ function onContainerDragOver(e: DragEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function importClipartAt(file: File, worldX: number, worldY: number) {
|
async function importClipartAt(file: File, worldX: number, worldY: number) {
|
||||||
const dataUrl = await readAsDataUrl(file)
|
await importImageFile(store, file, { position: { x: worldX, y: worldY } })
|
||||||
let width = 0
|
|
||||||
let height = 0
|
|
||||||
if (isSvgFile(file)) {
|
|
||||||
const text = await file.text()
|
|
||||||
const dims = parseSvgDimensions(text)
|
|
||||||
if (dims) {
|
|
||||||
width = dims.width
|
|
||||||
height = dims.height
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!width || !height) {
|
|
||||||
const img = await loadImage(dataUrl)
|
|
||||||
width = img.naturalWidth || 300
|
|
||||||
height = img.naturalHeight || 300
|
|
||||||
}
|
|
||||||
const asset = store.addAsset({
|
|
||||||
kind: 'image',
|
|
||||||
name: file.name,
|
|
||||||
src: dataUrl,
|
|
||||||
width,
|
|
||||||
height,
|
|
||||||
})
|
|
||||||
const layer = store.addLayer(asset.id)
|
|
||||||
if (layer) {
|
|
||||||
store.upsertKeyframe(layer.id, store.playhead, { x: worldX, y: worldY })
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function onContainerDrop(e: DragEvent) {
|
function onContainerDrop(e: DragEvent) {
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { computed, ref } from 'vue'
|
import { computed, ref, watch } from 'vue'
|
||||||
import {
|
import {
|
||||||
PhClock,
|
PhClock,
|
||||||
PhFolder,
|
PhFolder,
|
||||||
@@ -15,13 +15,23 @@ import type { Theme } from '@/composables/useTheme'
|
|||||||
import { Modal } from '@/components/ui'
|
import { Modal } from '@/components/ui'
|
||||||
import { t } from '@/i18n'
|
import { t } from '@/i18n'
|
||||||
|
|
||||||
defineProps<{ modelValue: boolean }>()
|
const props = defineProps<{ modelValue: boolean }>()
|
||||||
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
const emit = defineEmits<{ 'update:modelValue': [value: boolean] }>()
|
||||||
|
|
||||||
const store = useSceneStore()
|
const store = useSceneStore()
|
||||||
const clipart = useClipartStore()
|
const clipart = useClipartStore()
|
||||||
const { theme, setTheme } = useTheme()
|
const { theme, setTheme } = useTheme()
|
||||||
|
|
||||||
|
// Re-check the stored clipart folder whenever settings opens — covers the
|
||||||
|
// case where initial App.vue init raced ahead of IDB or the user opens
|
||||||
|
// settings before the drawer ever mounted.
|
||||||
|
watch(
|
||||||
|
() => props.modelValue,
|
||||||
|
(open) => {
|
||||||
|
if (open) void clipart.initialize()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
type TabId = 'document' | 'timeline' | 'clipart' | 'theme'
|
type TabId = 'document' | 'timeline' | 'clipart' | 'theme'
|
||||||
|
|
||||||
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
|
const tabs: { id: TabId; label: string; icon: typeof PhFrameCorners }[] = [
|
||||||
|
|||||||
@@ -51,3 +51,52 @@ export function parseSvgDimensions(svgText: string): Dimensions | null {
|
|||||||
export function isSvgFile(file: File): boolean {
|
export function isSvgFile(file: File): boolean {
|
||||||
return file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')
|
return file.type === 'image/svg+xml' || file.name.toLowerCase().endsWith('.svg')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ImageImportStore {
|
||||||
|
addAsset: (a: {
|
||||||
|
kind: 'image'
|
||||||
|
name: string
|
||||||
|
src: string
|
||||||
|
width: number
|
||||||
|
height: number
|
||||||
|
}) => { id: string }
|
||||||
|
addLayer: (assetId: string, name?: string) => { id: string } | null
|
||||||
|
upsertKeyframe?: (layerId: string, time: number, props: { x: number; y: number }) => void
|
||||||
|
playhead?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function importImageFile(
|
||||||
|
store: ImageImportStore,
|
||||||
|
file: File,
|
||||||
|
options?: { position?: { x: number; y: number } },
|
||||||
|
): Promise<{ assetId: string; layerId: string } | null> {
|
||||||
|
const dataUrl = await readAsDataUrl(file)
|
||||||
|
let width = 0
|
||||||
|
let height = 0
|
||||||
|
if (isSvgFile(file)) {
|
||||||
|
const text = await file.text()
|
||||||
|
const dims = parseSvgDimensions(text)
|
||||||
|
if (dims) {
|
||||||
|
width = dims.width
|
||||||
|
height = dims.height
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (!width || !height) {
|
||||||
|
const img = await loadImage(dataUrl)
|
||||||
|
width = img.naturalWidth || 300
|
||||||
|
height = img.naturalHeight || 300
|
||||||
|
}
|
||||||
|
const asset = store.addAsset({
|
||||||
|
kind: 'image',
|
||||||
|
name: file.name || 'image',
|
||||||
|
src: dataUrl,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
})
|
||||||
|
const layer = store.addLayer(asset.id)
|
||||||
|
if (!layer) return null
|
||||||
|
if (options?.position && store.upsertKeyframe) {
|
||||||
|
store.upsertKeyframe(layer.id, store.playhead ?? 0, options.position)
|
||||||
|
}
|
||||||
|
return { assetId: asset.id, layerId: layer.id }
|
||||||
|
}
|
||||||
+19
-2
@@ -51,14 +51,27 @@ export const useClipartStore = defineStore('clipart', {
|
|||||||
}),
|
}),
|
||||||
actions: {
|
actions: {
|
||||||
async initialize() {
|
async initialize() {
|
||||||
if (this.initialized) return
|
if (!this.supported) {
|
||||||
this.initialized = true
|
this.initialized = true
|
||||||
if (!this.supported) return
|
return
|
||||||
|
}
|
||||||
|
// Safe to call multiple times; a second call will no-op once a handle
|
||||||
|
// has been loaded. If the previous attempt failed silently (e.g. IDB
|
||||||
|
// race on app start), a later call from Settings/drawer can retry.
|
||||||
|
if (this.handle) {
|
||||||
|
this.initialized = true
|
||||||
|
return
|
||||||
|
}
|
||||||
const handle = await loadStoredClipartFolder()
|
const handle = await loadStoredClipartFolder()
|
||||||
|
this.initialized = true
|
||||||
if (!handle) return
|
if (!handle) return
|
||||||
this.handle = handle
|
this.handle = handle
|
||||||
this.folderName = handle.name
|
this.folderName = handle.name
|
||||||
|
try {
|
||||||
await this.refresh()
|
await this.refresh()
|
||||||
|
} catch {
|
||||||
|
/* permission may require a user gesture; items will populate on next open */
|
||||||
|
}
|
||||||
},
|
},
|
||||||
async pickFolder() {
|
async pickFolder() {
|
||||||
this.error = null
|
this.error = null
|
||||||
@@ -105,6 +118,10 @@ export const useClipartStore = defineStore('clipart', {
|
|||||||
setOpen(v: boolean) {
|
setOpen(v: boolean) {
|
||||||
this.open = v
|
this.open = v
|
||||||
writeDrawerOpen(v)
|
writeDrawerOpen(v)
|
||||||
|
if (v && this.handle && this.items.length === 0 && !this.loading) {
|
||||||
|
// User gesture opens the drawer — good moment to (re-)request access
|
||||||
|
void this.refresh()
|
||||||
|
}
|
||||||
},
|
},
|
||||||
toggle() {
|
toggle() {
|
||||||
this.setOpen(!this.open)
|
this.setOpen(!this.open)
|
||||||
|
|||||||
+1
-1
@@ -1,2 +1,2 @@
|
|||||||
export const APP_VERSION = '0.5.1'
|
export const APP_VERSION = '0.5.2'
|
||||||
export const APP_NAME = 'RUWIO'
|
export const APP_NAME = 'RUWIO'
|
||||||
Reference in new issue
Block a user