feat(view): import images from file or clipboard
This commit is contained in:
@@ -8,6 +8,7 @@ import { labelForTool } from "./toolLabels";
|
|||||||
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
import { resolveTransformTargetBounds } from "@editor/transform-targets";
|
||||||
import { getSelectionSummary } from "./selectionSummary";
|
import { getSelectionSummary } from "./selectionSummary";
|
||||||
import { useAppState } from "./useAppState";
|
import { useAppState } from "./useAppState";
|
||||||
|
import { useImageImport } from "./useImageImport";
|
||||||
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
import { useViewportActivityIsland } from "./useViewportActivityIsland";
|
||||||
import "./index.css";
|
import "./index.css";
|
||||||
|
|
||||||
@@ -19,6 +20,7 @@ export function App({ app }: AppProps) {
|
|||||||
const state = useAppState(app.store);
|
const state = useAppState(app.store);
|
||||||
const zoomPercent = Math.round(state.editor.viewport.zoom * 100);
|
const zoomPercent = Math.round(state.editor.viewport.zoom * 100);
|
||||||
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
const viewportActivityIsland = useViewportActivityIsland(state.editor.viewport);
|
||||||
|
const imageImport = useImageImport(app.store);
|
||||||
const [layersOpen, setLayersOpen] = useState(false);
|
const [layersOpen, setLayersOpen] = useState(false);
|
||||||
const selectionSummary = getSelectionSummary(state.document, state.editor.selection);
|
const selectionSummary = getSelectionSummary(state.document, state.editor.selection);
|
||||||
const transformBounds = state.editor.transformSession
|
const transformBounds = state.editor.transformSession
|
||||||
@@ -27,6 +29,7 @@ export function App({ app }: AppProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="relative h-full overflow-hidden bg-background text-foreground">
|
<main className="relative h-full overflow-hidden bg-background text-foreground">
|
||||||
|
{imageImport.input}
|
||||||
<header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white">
|
<header className="pointer-events-none absolute inset-x-0 top-0 z-10 flex h-8 items-center justify-between px-3 text-white">
|
||||||
<h1 className="text-sm font-medium">Image Studio</h1>
|
<h1 className="text-sm font-medium">Image Studio</h1>
|
||||||
<div className="text-xs">
|
<div className="text-xs">
|
||||||
|
|||||||
108
view/useImageImport.tsx
Normal file
108
view/useImageImport.tsx
Normal file
@@ -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<HTMLInputElement | null>(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 = (
|
||||||
|
<input
|
||||||
|
ref={inputRef}
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
className="hidden"
|
||||||
|
onChange={(event) => {
|
||||||
|
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;
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user