From ed4d419c4d842be9e2dd2ae33235c6f82adaae26 Mon Sep 17 00:00:00 2001 From: syntaxbullet Date: Fri, 3 Jul 2026 16:19:44 +0200 Subject: [PATCH] feat(view): import images from file or clipboard --- view/App.tsx | 3 ++ view/useImageImport.tsx | 108 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 view/useImageImport.tsx diff --git a/view/App.tsx b/view/App.tsx index f8f4d6b..6ab8144 100644 --- a/view/App.tsx +++ b/view/App.tsx @@ -8,6 +8,7 @@ import { labelForTool } from "./toolLabels"; import { resolveTransformTargetBounds } from "@editor/transform-targets"; import { getSelectionSummary } from "./selectionSummary"; import { useAppState } from "./useAppState"; +import { useImageImport } from "./useImageImport"; import { useViewportActivityIsland } from "./useViewportActivityIsland"; import "./index.css"; @@ -19,6 +20,7 @@ export function App({ app }: AppProps) { const state = useAppState(app.store); const zoomPercent = Math.round(state.editor.viewport.zoom * 100); const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport); + const imageImport = useImageImport(app.store); const [layersOpen, setLayersOpen] = useState(false); const selectionSummary = getSelectionSummary(state.document, state.editor.selection); const transformBounds = state.editor.transformSession @@ -27,6 +29,7 @@ export function App({ app }: AppProps) { return (
+ {imageImport.input}

Image Studio

diff --git a/view/useImageImport.tsx b/view/useImageImport.tsx new file mode 100644 index 0000000..0c2c6d1 --- /dev/null +++ b/view/useImageImport.tsx @@ -0,0 +1,108 @@ +import { useCallback, useEffect, useRef } from "react"; +import { commandIds } from "@commands/ids"; +import type { AppStore } from "@editor/store"; + +export function useImageImport(store: AppStore) { + const inputRef = useRef(null); + + const importFile = useCallback( + async (file: File) => { + if (!file.type.startsWith("image/")) return; + + const source = URL.createObjectURL(file); + const intrinsicSize = await loadImageSize(source); + const state = store.getState(); + const artboard = state.editor.selection.artboardId + ? state.document.artboards.find((candidate) => candidate.id === state.editor.selection.artboardId) + : state.document.artboards[0]; + + if (!artboard) { + URL.revokeObjectURL(source); + return; + } + + const assetId = crypto.randomUUID(); + const layerId = crypto.randomUUID(); + const center = state.editor.viewport.center; + + store.dispatch(commandIds.documentAddAsset, { + asset: { + id: assetId, + name: file.name, + mimeType: file.type, + source, + intrinsicSize, + }, + }); + store.dispatch(commandIds.documentAddImageLayer, { + artboardId: artboard.id, + layer: { + id: layerId, + type: "image", + name: file.name, + visible: true, + locked: false, + opacity: 1, + assetId, + transform: { + position: { x: center.x - intrinsicSize.w / 2, y: center.y - intrinsicSize.h / 2 }, + scale: { x: 1, y: 1 }, + rotation: 0, + }, + }, + }); + store.dispatch(commandIds.selectionSet, { artboardId: artboard.id, layerIds: [layerId] }); + }, + [store], + ); + + const openFilePicker = useCallback(() => inputRef.current?.click(), []); + + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (event.defaultPrevented || event.key.toLowerCase() !== "o" || (!event.metaKey && !event.ctrlKey)) return; + event.preventDefault(); + openFilePicker(); + }; + + const handlePaste = (event: ClipboardEvent) => { + const file = [...(event.clipboardData?.files ?? [])].find((candidate) => candidate.type.startsWith("image/")); + if (!file) return; + + event.preventDefault(); + void importFile(file); + }; + + window.addEventListener("keydown", handleKeyDown); + window.addEventListener("paste", handlePaste); + return () => { + window.removeEventListener("keydown", handleKeyDown); + window.removeEventListener("paste", handlePaste); + }; + }, [importFile, openFilePicker]); + + const input = ( + { + const file = event.currentTarget.files?.[0]; + event.currentTarget.value = ""; + if (file) void importFile(file); + }} + /> + ); + + return { input, openFilePicker, importFile }; +} + +function loadImageSize(source: string): Promise<{ w: number; h: number }> { + return new Promise((resolve, reject) => { + const image = new Image(); + image.onload = () => resolve({ w: image.naturalWidth, h: image.naturalHeight }); + image.onerror = () => reject(new Error("Failed to load image")); + image.src = source; + }); +}