From 07b0ef2659186f06b47ba805aaed9d3234c33027 Mon Sep 17 00:00:00 2001
From: th
Date: Mon, 20 Apr 2026 14:05:48 +0200
Subject: [PATCH] 0.5.2: paste images, shared import helper, clipart
persistence
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 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)
---
package.json | 2 +-
src/App.vue | 38 ++++++++++++++++++++++
src/components/AssetSidebar.vue | 31 ++----------------
src/components/CanvasStage.vue | 30 ++----------------
src/components/DocumentSettings.vue | 14 +++++++--
src/lib/fileLoader.ts | 49 +++++++++++++++++++++++++++++
src/stores/clipart.ts | 25 ++++++++++++---
src/version.ts | 2 +-
8 files changed, 126 insertions(+), 65 deletions(-)
diff --git a/package.json b/package.json
index e20d3fe..c9dfb47 100644
--- a/package.json
+++ b/package.json
@@ -1,7 +1,7 @@
{
"name": "animatr",
"private": true,
- "version": "0.5.1",
+ "version": "0.5.2",
"type": "module",
"scripts": {
"dev": "vite",
diff --git a/src/App.vue b/src/App.vue
index feba5e2..ae6fec9 100644
--- a/src/App.vue
+++ b/src/App.vue
@@ -44,6 +44,8 @@ import {
setLastOpenedId,
type StoredProject,
} from './lib/projectStore'
+import { importImageFile } from './lib/fileLoader'
+import { useClipartStore } from './stores/clipart'
import { useSceneStore } from './stores/scene'
const settingsOpen = ref(false)
@@ -131,6 +133,7 @@ const currentProjectName = ref(null)
usePlaybackLoop()
const store = useSceneStore()
+const clipart = useClipartStore()
const history = useHistory()
function rewind() {
@@ -278,15 +281,50 @@ function onKeyDown(e: KeyboardEvent) {
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(() => {
document.addEventListener('keydown', onKeyDown)
document.addEventListener('contextmenu', onDocumentContextMenu)
+ document.addEventListener('paste', onPaste)
window.addEventListener('resize', onWindowResize)
+ void clipart.initialize()
bootWelcome()
})
onBeforeUnmount(() => {
document.removeEventListener('keydown', onKeyDown)
document.removeEventListener('contextmenu', onDocumentContextMenu)
+ document.removeEventListener('paste', onPaste)
window.removeEventListener('resize', onWindowResize)
})
diff --git a/src/components/AssetSidebar.vue b/src/components/AssetSidebar.vue
index 0cd102d..3749bf7 100644
--- a/src/components/AssetSidebar.vue
+++ b/src/components/AssetSidebar.vue
@@ -16,7 +16,7 @@ import {
PhUploadSimple,
} from '@phosphor-icons/vue'
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 { confirmDialog, promptDialog } from '@/components/ui'
import { t } from '@/i18n'
@@ -161,34 +161,7 @@ async function handleFileSelected(event: Event) {
}
async function importFile(file: File) {
- 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,
- src: dataUrl,
- width,
- height,
- })
- store.addLayer(asset.id)
+ await importImageFile(store, file)
}
function triggerUpload() {
diff --git a/src/components/CanvasStage.vue b/src/components/CanvasStage.vue
index 4cafcd2..6490841 100644
--- a/src/components/CanvasStage.vue
+++ b/src/components/CanvasStage.vue
@@ -4,7 +4,7 @@ import type Konva from 'konva'
import { PhCornersIn, PhCrop, PhMaskHappy, PhPencilSimple } from '@phosphor-icons/vue'
import { useSceneStore } from '@/stores/scene'
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 { drawSceneFrame, loadSceneImages } from '@/lib/render'
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) {
- 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,
- src: dataUrl,
- width,
- height,
- })
- const layer = store.addLayer(asset.id)
- if (layer) {
- store.upsertKeyframe(layer.id, store.playhead, { x: worldX, y: worldY })
- }
+ await importImageFile(store, file, { position: { x: worldX, y: worldY } })
}
function onContainerDrop(e: DragEvent) {
diff --git a/src/components/DocumentSettings.vue b/src/components/DocumentSettings.vue
index a4ae85a..ff61167 100644
--- a/src/components/DocumentSettings.vue
+++ b/src/components/DocumentSettings.vue
@@ -1,5 +1,5 @@
|