diff --git a/app/javascript/controllers/image_paste_controller.js b/app/javascript/controllers/image_paste_controller.js new file mode 100644 index 0000000..2e8b386 --- /dev/null +++ b/app/javascript/controllers/image_paste_controller.js @@ -0,0 +1,121 @@ +import { Controller } from "@hotwired/stimulus" + +export default class extends Controller { + static targets = ["input", "dropzone"] + + connect() { + // Listen for paste events on the dropzone + this.dropzoneTarget.addEventListener("paste", this.handlePaste.bind(this)) + } + + disconnect() { + this.dropzoneTarget.removeEventListener("paste", this.handlePaste.bind(this)) + } + + handlePaste(e) { + e.preventDefault() + e.stopPropagation() + + const clipboardData = e.clipboardData || e.originalEvent.clipboardData + + // First, try to get image data + for (let item of clipboardData.items) { + if (item.type.indexOf("image") !== -1) { + const blob = item.getAsFile() + this.handleImageBlob(blob) + return + } + } + + // If no image found, check for SVG text + const text = clipboardData.getData("text/plain") + if (text && this.isSVG(text)) { + this.handleSVGText(text) + return + } + } + + isSVG(text) { + // Check if the text looks like SVG code + const trimmed = text.trim() + return trimmed.startsWith("") + } + + handleSVGText(svgText) { + // Validate file size (2MB) + const size = new Blob([svgText]).size + if (size > 2 * 1024 * 1024) { + alert("SVG code is too large (must be less than 2MB)") + return + } + + // Create a blob from the SVG text + const blob = new Blob([svgText], { type: "image/svg+xml" }) + + // Create a File object + const file = new File([blob], `pasted-svg-${Date.now()}.svg`, { + type: "image/svg+xml" + }) + + // Create a DataTransfer object to set files on the input + const dataTransfer = new DataTransfer() + dataTransfer.items.add(file) + this.inputTarget.files = dataTransfer.files + + // Trigger change event to update preview (file-drop controller will handle it) + const event = new Event("change", { bubbles: true }) + this.inputTarget.dispatchEvent(event) + + // Visual feedback + this.dropzoneTarget.classList.add("border-green-500", "bg-green-50") + setTimeout(() => { + this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50") + }, 500) + } + + handleImageBlob(blob) { + // Validate file type + const validTypes = ["image/png", "image/jpg", "image/jpeg", "image/gif", "image/svg+xml"] + if (!validTypes.includes(blob.type)) { + alert("Please paste a PNG, JPG, GIF, or SVG image") + return + } + + // Validate file size (2MB) + if (blob.size > 2 * 1024 * 1024) { + alert("Image size must be less than 2MB") + return + } + + // Create a File object from the blob with a default name + const file = new File([blob], `pasted-image-${Date.now()}.${this.getExtension(blob.type)}`, { + type: blob.type + }) + + // Create a DataTransfer object to set files on the input + const dataTransfer = new DataTransfer() + dataTransfer.items.add(file) + this.inputTarget.files = dataTransfer.files + + // Trigger change event to update preview (file-drop controller will handle it) + const event = new Event("change", { bubbles: true }) + this.inputTarget.dispatchEvent(event) + + // Visual feedback + this.dropzoneTarget.classList.add("border-green-500", "bg-green-50") + setTimeout(() => { + this.dropzoneTarget.classList.remove("border-green-500", "bg-green-50") + }, 500) + } + + getExtension(mimeType) { + const extensions = { + "image/png": "png", + "image/jpeg": "jpg", + "image/jpg": "jpg", + "image/gif": "gif", + "image/svg+xml": "svg" + } + return extensions[mimeType] || "png" + } +} diff --git a/config/initializers/permissions_policy.rb b/config/initializers/permissions_policy.rb new file mode 100644 index 0000000..f7df04e --- /dev/null +++ b/config/initializers/permissions_policy.rb @@ -0,0 +1,19 @@ +# Configure the Permissions-Policy header +# See https://api.rubyonrails.org/classes/ActionDispatch/PermissionsPolicy.html + +Rails.application.config.permissions_policy do |f| + # Disable sensitive browser features for security + f.camera :none + f.gyroscope :none + f.microphone :none + f.payment :none + f.usb :none + f.magnetometer :none + + # You can enable specific features as needed: + # f.fullscreen :self + # f.geolocation :self + + # You can also allow specific origins: + # f.payment :self, "https://secure.example.com" +end