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 @@