diff --git a/src/Editor/Editor.jsx b/src/Editor/Editor.jsx index 2977c8234..1168995bb 100644 --- a/src/Editor/Editor.jsx +++ b/src/Editor/Editor.jsx @@ -341,6 +341,26 @@ class Editor extends EditorCore { this.watchForHover(); + // Track mouse position to use whn pasting images πŸ‘€ + this._lastMouseX = 0; + this._lastMouseY = 0; + this._mouseMoveHandler = (e) => { + this._lastMouseX = e.clientX; + this._lastMouseY = e.clientY; + }; + document.addEventListener('mousemove', this._mouseMoveHandler); + + // Paste event listener β€” fires because we no longer call e.preventDefault() on the + // Cmd+V keydown for 'paste'. This gives us clipboardData with real File objects + // (original filenames, Finder file copies, Discord/Figma images, etc.) + this._pasteHandler = (e) => { + if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return; + e.preventDefault(); + this._handlePasteEvent(e); + }; + document.addEventListener('paste', this._pasteHandler); + + // check to see if we're in the app if (window.__TAURI__) { @@ -368,6 +388,13 @@ class Editor extends EditorCore { } + // apparently need this for cleanup -H.A. + componentWillUnmount = () => { + document.removeEventListener('mousemove', this._mouseMoveHandler); + document.removeEventListener('paste', this._pasteHandler); + window.removeEventListener('resize', this.resizeProps.onWindowResize); + } + componentDidUpdate = (prevProps, prevState) => { if (this.state.previewPlaying && !prevState.previewPlaying) { this.project.view.canvas.focus(); @@ -1172,6 +1199,7 @@ class Editor extends EditorCore { onEyedropperPickedColor={this.onEyedropperPickedColor} createAssets={this.createAssets} importProjectAsWickFile={this.importProjectAsWickFile} + openProjectFile={(file) => this.handleWickFileLoad({ target: { files: [file] } })} onRef={ref => this.canvasComponent = ref} />); }} diff --git a/src/Editor/EditorCore.jsx b/src/Editor/EditorCore.jsx index 851b3c121..debca51a0 100644 --- a/src/Editor/EditorCore.jsx +++ b/src/Editor/EditorCore.jsx @@ -19,6 +19,7 @@ import { Component } from 'react'; import queryString from 'query-string'; +// import { readFile } from '@tauri-apps/plugin-fs'; // import VideoExport from './export/VideoExport'; import GIFExport from './export/GIFExport'; import GIFImport from './import/GIFImport'; @@ -1018,7 +1019,35 @@ class EditorCore extends Component { * @param {File} file - File object to create an asset of. * @param {Function} callback - (optional) Callback to return asset to. If the import was unsuccessful, null is sent to the callback. */ - importFileAsAsset = (file, callback) => { + importFileAsAsset = async (file, callback) => { + // Content-based dedup: fingerprint the new file (size + first 64 bytes) + // then compare against all existing assets via their data-URL base64. + try { + const fpBytes = new Uint8Array(await file.slice(0, 64).arrayBuffer()); + const newFp = file.size + ':' + btoa(String.fromCharCode(...fpBytes)); + + for (const asset of this.project.getAssets()) { + const src = asset.src; + if (!src || !src.includes(',')) continue; + const base64 = src.split(',')[1]; + // Compute byte size from base64 length minus padding + const paddingCount = (base64.match(/=/g) || []).length; + const assetSize = Math.floor(base64.length / 4 * 3) - paddingCount; + if (assetSize !== file.size) continue; // fast size pre-check + // Decode first 88 base64 chars β†’ first β‰₯64 raw bytes + try { + const decoded = atob(base64.slice(0, 88)); + const assetBytes = new Uint8Array(Math.min(64, decoded.length)); + for (let i = 0; i < assetBytes.length; i++) assetBytes[i] = decoded.charCodeAt(i); + const assetFp = assetSize + ':' + btoa(String.fromCharCode(...assetBytes)); + if (assetFp === newFp) { + if (callback) callback(asset); + return; + } + } catch (_) {} + } + } catch (_) {} + this.project.importFile(file, (asset) => { if (callback) callback(asset); @@ -1921,12 +1950,38 @@ class EditorCore extends Component { this.projectDidChange({ actionName: "Clear Code Editor Error" }); } + /** + * Returns a lightweight fingerprint of the current system clipboard image (size + first 64 bytes). + * Used to detect when a clipboard image has become "stale" after an in-editor copy. + */ + _getClipboardImageFingerprint = async () => { + if (!navigator.clipboard || !navigator.clipboard.read) return null; + try { + const items = await navigator.clipboard.read(); + for (const item of items) { + const imageType = item.types.find(t => t.startsWith('image/')); + if (imageType) { + const blob = await item.getType(imageType); + const bytes = new Uint8Array(await blob.slice(0, 64).arrayBuffer()); + return blob.size + ':' + btoa(String.fromCharCode(...bytes)); + } + } + } catch (e) {} + return null; + } + /** * Copies the selection state and selected objects to the clipboard. */ copySelectionToClipboard = () => { if (this.project.copySelectionToClipboard()) { this.projectDidChange({ actionName: "Copy Selection" }); + // Mark whatever image is currently in the system clipboard as stale, + // so the next paste prefers the wick clipboard over it. + this._getClipboardImageFingerprint().then(fp => { + if (fp) localStorage.setItem('wickEditorStaleClipboardFP', fp); + else localStorage.removeItem('wickEditorStaleClipboardFP'); + }); } else { this.toast('There is nothing to copy.', 'warning'); } @@ -1949,16 +2004,123 @@ class EditorCore extends Component { cutSelectionToClipboard = () => { if (this.project.cutSelectionToClipboard()) { this.projectDidChange({ actionName: "Cut Selection" }); + // Same stale-image marking as copy + this._getClipboardImageFingerprint().then(fp => { + if (fp) localStorage.setItem('wickEditorStaleClipboardFP', fp); + else localStorage.removeItem('wickEditorStaleClipboardFP'); + }); } else { this.toast('There is nothing to duplicate.', 'warning'); } } /** - * Attempts to paste in objects on the clipboard if they are available. - * @return {[type]} [description] + * Called by the hotkey handler for Cmd+V */ pasteFromClipboard = () => { + // No-op β€” handled by the paste event listener registered in Editor.componentDidMount + } + + /** + * Core paste handler β€” called from the document 'paste' event listener. + * Uses e.clipboardData which gives us real File objects with filenames and etc. -H.A. + */ + _handlePasteEvent = async (e) => { + const items = Array.from((e.clipboardData && e.clipboardData.items) || []); + + // imageFile β€” what gets imported (PNG JPEG etc. or TIFFβ†’PNG converted) + // rawFileForFP β€” raw bytes used for fingerprint (must match _getClipboardImageFingerprint) + let imageFile = null, rawFileForFP = null; + + // Pass 1: directly usable image formats (PNG, JPEG, GIF, WEBP) + // getAsFile() returns a File with the ORIGINAL filename when copied from Finder/Discord/etc. + for (const item of items) { + if (item.kind !== 'file') continue; + if (['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(item.type)) { + imageFile = rawFileForFP = item.getAsFile(); + break; + } + } + + // Pass 2: TIFF (macOS clipboard for Finder/app copies) β†’ convert to PNG via canvas + if (!imageFile) { + for (const item of items) { + if (item.kind !== 'file' || item.type !== 'image/tiff') continue; + const tiffFile = item.getAsFile(); + if (!tiffFile) continue; + rawFileForFP = tiffFile; + try { + const pngBlob = await new Promise((resolve, reject) => { + const url = URL.createObjectURL(tiffFile); + const img = new Image(); + img.onload = () => { + const c = document.createElement('canvas'); + c.width = img.naturalWidth; c.height = img.naturalHeight; + c.getContext('2d').drawImage(img, 0, 0); + c.toBlob(b => { URL.revokeObjectURL(url); resolve(b); }, 'image/png'); + }; + img.onerror = () => { URL.revokeObjectURL(url); reject(); }; + img.src = url; + }); + // Replace .tiff/.tif extension with .png in the filename + const pngName = (tiffFile.name || 'image.tiff').replace(/\.tiff?$/i, '.png'); + imageFile = new File([pngBlob], pngName, { type: 'image/png' }); + } catch (_) { + rawFileForFP = null; + } + break; + } + } + + if (imageFile && rawFileForFP) { + // Fingerprint from rawFileForFP keeps consistency with _getClipboardImageFingerprint + const fpBytes = new Uint8Array(await rawFileForFP.slice(0, 64).arrayBuffer()); + const fp = rawFileForFP.size + ':' + btoa(String.fromCharCode(...fpBytes)); + const stale = localStorage.getItem('wickEditorStaleClipboardFP'); + + if (!stale || fp !== stale) { + // Determine final filename β€” use original when it's preseny, + // fall back to sequential name for generic browser-generated names + let finalFile = imageFile; + const genericNames = ['image', 'image.png', 'image.jpg', 'image.jpeg', 'image.gif', 'image.webp']; + if (!imageFile.name || genericNames.includes(imageFile.name.toLowerCase())) { + this._pasteImageCount = (this._pasteImageCount || 0) + 1; + const num = String(this._pasteImageCount).padStart(2, '0'); + const ext = (imageFile.type || 'image/png').split('/')[1] || 'png'; + // if it's a generic file name, make sure to add the number counter at the end to avoid having too many files of the same name (ex: full asset library all just "image.png") + finalFile = new File([imageFile], genericNames.includes(imageFile.name.toLowerCase())?`${imageFile.name.replaceAll(".","-")}-${num}.${ext}`:`pasted-image-${num}.${ext}`, { type: imageFile.type }); + } + + const loc = { x: this._lastMouseX || 0, y: this._lastMouseY || 0 }; + this.importFileAsAsset(finalFile, (asset) => { + if (!asset) return; + const paper = this.project.view.paper; + const canvasPos = paper.project.view.element.getBoundingClientRect(); + // If the mouse is outside the canvas, paste at canvas centeer instead -H.A. :P + const relX = loc.x - canvasPos.left; + const relY = loc.y - canvasPos.top; + const onCanvas = relX >= 0 && relX <= canvasPos.width && relY >= 0 && relY <= canvasPos.height; + const dropPoint = paper.view.viewToProject( + new window.paper.Point( + onCanvas ? relX : canvasPos.width / 2, + onCanvas ? relY : canvasPos.height / 2 + ) + ); + this.project.createImagePathFromAsset(asset, dropPoint.x, dropPoint.y, (path) => { + if (path) { + this.clearSelection(); + this.selectObject(path); + this.project.copySelectionToClipboard(); + } + localStorage.setItem('wickEditorStaleClipboardFP', fp); + this.projectDidChange({ actionName: "Paste Image from Clipboard" }); + }); + }); + return; + } + } + + // No image in paste event (or image was stale) β€” fall back to Wick clipboard if (this.project.pasteClipboardContents()) { this.projectDidChange({ actionName: "Paste from Clipboard" }); } else { diff --git a/src/Editor/Panels/Canvas/Canvas.jsx b/src/Editor/Panels/Canvas/Canvas.jsx index 9f80f0c1f..65e45328b 100644 --- a/src/Editor/Panels/Canvas/Canvas.jsx +++ b/src/Editor/Panels/Canvas/Canvas.jsx @@ -83,10 +83,15 @@ const canvasTarget = { let draggedItem = monitor.getItem(); if(draggedItem.files && draggedItem.files.length > 0) { // Dropped a file from native filesystem - if(draggedItem.files[0].name.endsWith('.wick')) { + var file = draggedItem.files[0]; + var name = file.name; + if(name.endsWith('.wick')) { // Wick Project (.wick file) - var file = draggedItem.files[0]; props.importProjectAsWickFile(file); + } else if(file.type === 'video/mp4' || name.endsWith('.mp4') || + file.type === 'application/pdf' || name.endsWith('.pdf')) { + // MP4/PDF β†’ open as new project + props.openProjectFile(file); } else { // Assets (images, sounds, etc) props.createAssets(draggedItem.files, [], {create: true, location: dropLocation}); diff --git a/src/Editor/hotKeyMap.js b/src/Editor/hotKeyMap.js index fa97c9ac5..3930252ce 100644 --- a/src/Editor/hotKeyMap.js +++ b/src/Editor/hotKeyMap.js @@ -560,8 +560,10 @@ class HotKeyInterface extends Object { wrapHotkeyFunction = (e, name, fn) => { if(e.target.tagName !== 'INPUT' && e.target.tagName !== 'TEXTAREA') { - // If we are not on a text input area, use the hotkey's function - e.preventDefault(); + // If we are not on a text input area, use the hotkey's function. + // Don't prevent default for 'paste' β€” we need the browser to fire the paste + // event so our listener can access clipboardData (files, real filenames, etc.) + if (name !== 'paste') e.preventDefault(); fn(); // Start the repeat timers if this hotkey is repeatable