From cf188896be1401f81e37d22192675c1a3892d825 Mon Sep 17 00:00:00 2001 From: Timothy Cardoza Date: Thu, 12 Mar 2026 16:43:27 -0500 Subject: [PATCH] HEIC support --- bun.lock | 3 ++ package.json | 1 + src/components/TaskEditor.tsx | 2 +- src/lib/extensions/image-upload.ts | 2 +- src/lib/extensions/image.ts | 81 ++++++++++++++++++++++++++++++ src/lib/extensions/video.ts | 74 +++++++++++++++++++++++++++ src/lib/utils.ts | 26 +++++++++- 7 files changed, 185 insertions(+), 4 deletions(-) create mode 100644 src/lib/extensions/image.ts diff --git a/bun.lock b/bun.lock index 1c735cf..78f30cc 100644 --- a/bun.lock +++ b/bun.lock @@ -32,6 +32,7 @@ "autoprefixer": "^10.4.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "heic2any": "^0.0.4", "lucide-solid": "^0.563.0", "pm2": "^6.0.14", "pocketbase": "^0.26.8", @@ -825,6 +826,8 @@ "hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="], + "heic2any": ["heic2any@0.0.4", "", {}, "sha512-3lLnZiDELfabVH87htnRolZ2iehX9zwpRyGNz22GKXIu0fznlblf0/ftppXKNqS26dqFSeqfIBhAmAj/uSp0cA=="], + "html-entities": ["html-entities@2.3.3", "", {}, "sha512-DV5Ln36z34NNTDgnz0EWGBLZENelNAtkiFA4kyNOG2tDI6Mz1uSWiq1wAKdyjnJwyDiDO7Fa2SO1CTxPXL8VxA=="], "http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="], diff --git a/package.json b/package.json index 0f66e96..bbe53a0 100644 --- a/package.json +++ b/package.json @@ -42,6 +42,7 @@ "autoprefixer": "^10.4.23", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", + "heic2any": "^0.0.4", "lucide-solid": "^0.563.0", "pm2": "^6.0.14", "pocketbase": "^0.26.8", diff --git a/src/components/TaskEditor.tsx b/src/components/TaskEditor.tsx index 2f59e23..6271741 100644 --- a/src/components/TaskEditor.tsx +++ b/src/components/TaskEditor.tsx @@ -4,7 +4,7 @@ import StarterKit from "@tiptap/starter-kit"; import Placeholder from "@tiptap/extension-placeholder"; import TaskList from "@tiptap/extension-task-list"; import { CustomTaskItem } from "./CustomTaskItem"; -import Image from "@tiptap/extension-image"; +import { CustomImage as Image } from "@/lib/extensions/image"; import Underline from "@tiptap/extension-underline"; import { X } from "lucide-solid"; import { cn } from "@/lib/utils"; diff --git a/src/lib/extensions/image-upload.ts b/src/lib/extensions/image-upload.ts index 3715f3a..5c06f21 100644 --- a/src/lib/extensions/image-upload.ts +++ b/src/lib/extensions/image-upload.ts @@ -27,7 +27,7 @@ export const ImageUpload = Extension.create({ const input = document.createElement("input"); input.type = "file"; - input.accept = "image/*"; + input.accept = "image/*,.heic,.heif"; input.onchange = async () => { if (input.files?.length) { const originalFile = input.files[0]; diff --git a/src/lib/extensions/image.ts b/src/lib/extensions/image.ts new file mode 100644 index 0000000..d9db95b --- /dev/null +++ b/src/lib/extensions/image.ts @@ -0,0 +1,81 @@ +import Image from "@tiptap/extension-image"; + +export const CustomImage = Image.extend({ + name: "image", + + group: "block", + selectable: true, + draggable: true, + + addKeyboardShortcuts() { + return { + Backspace: () => { + const { selection } = this.editor.state; + if (!selection.empty) { + const selectedNode = this.editor.state.doc.nodeAt(selection.from); + if (selectedNode?.type.name === this.name) { + return true; // Prevent default deletion if image is selected + } + } + return false; + }, + Delete: () => { + const { selection } = this.editor.state; + if (!selection.empty) { + const selectedNode = this.editor.state.doc.nodeAt(selection.from); + if (selectedNode?.type.name === this.name) { + return true; // Prevent default deletion if image is selected + } + } + return false; + }, + }; + }, + + addNodeView() { + return ({ node, getPos, editor }) => { + const wrapper = document.createElement('div'); + // Give it position: relative and flex/inline-block to wrap the image tightly + wrapper.classList.add('image-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-fit', 'max-w-full'); + wrapper.style.display = 'block'; // Block level to not mess up flow + wrapper.style.marginInline = 'auto'; // Center if desired, or let it flow + + const img = document.createElement('img'); + Object.entries(node.attrs).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + img.setAttribute(key, value); + } + }); + img.classList.add('rounded-lg', 'border', 'border-border', 'max-w-full', 'h-auto', 'cursor-default'); + + // Delete button overlay + const overlay = document.createElement('div'); + overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10'); + + const deleteBtn = document.createElement('button'); + deleteBtn.innerHTML = ''; + deleteBtn.classList.add('p-1', 'cursor-pointer'); + + deleteBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (typeof getPos === 'function') { + const pos = getPos(); + if (typeof pos === 'number') { + editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize }); + } + } + }; + + overlay.appendChild(deleteBtn); + wrapper.appendChild(img); + wrapper.appendChild(overlay); + + return { + dom: wrapper, + stopEvent: () => true, + ignoreMutation: () => true, + }; + }; + }, +}); diff --git a/src/lib/extensions/video.ts b/src/lib/extensions/video.ts index 3e2f73c..d5e84fb 100644 --- a/src/lib/extensions/video.ts +++ b/src/lib/extensions/video.ts @@ -42,6 +42,80 @@ export const Video = Node.create({ return ['video', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)]; }, + addKeyboardShortcuts() { + return { + Backspace: () => { + const { selection } = this.editor.state; + if (!selection.empty) { + const selectedNode = this.editor.state.doc.nodeAt(selection.from); + if (selectedNode?.type.name === this.name) { + return true; // Prevent default deletion + } + } + return false; + }, + Delete: () => { + const { selection } = this.editor.state; + if (!selection.empty) { + const selectedNode = this.editor.state.doc.nodeAt(selection.from); + if (selectedNode?.type.name === this.name) { + return true; // Prevent default deletion + } + } + return false; + }, + }; + }, + + addNodeView() { + return ({ node, getPos, editor }) => { + const wrapper = document.createElement('div'); + wrapper.classList.add('video-wrapper', 'relative', 'inline-block', 'my-4', 'group', 'w-full', 'max-w-full'); + wrapper.style.display = 'block'; + + const video = document.createElement('video'); + Object.entries(node.attrs).forEach(([key, value]) => { + if (value !== null && value !== undefined) { + video.setAttribute(key, value); + } + }); + // Apply default attributes if not present + if (!video.hasAttribute('controls')) { + video.setAttribute('controls', 'true'); + } + video.classList.add('w-full', 'rounded-lg', 'border', 'border-border/50', 'shadow-sm', 'overflow-hidden'); + + // Delete button overlay + const overlay = document.createElement('div'); + overlay.classList.add('absolute', 'top-2', 'right-2', 'hidden', 'group-hover:flex', 'bg-background/80', 'backdrop-blur-sm', 'p-1', 'rounded-lg', 'border', 'border-border', 'shadow-sm', 'z-10'); + + const deleteBtn = document.createElement('button'); + deleteBtn.innerHTML = ''; + deleteBtn.classList.add('p-1', 'cursor-pointer'); + + deleteBtn.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + if (typeof getPos === 'function') { + const pos = getPos(); + if (typeof pos === 'number') { + editor.commands.deleteRange({ from: pos, to: pos + node.nodeSize }); + } + } + }; + + overlay.appendChild(deleteBtn); + wrapper.appendChild(video); + wrapper.appendChild(overlay); + + return { + dom: wrapper, + stopEvent: () => true, // Stops events from propagating to editor (makes the input usable) + ignoreMutation: () => true, + }; + }; + }, + addCommands() { return { setVideo: (options: { src: string }) => ({ commands }) => { diff --git a/src/lib/utils.ts b/src/lib/utils.ts index ebc561b..cbfa62e 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -1,5 +1,6 @@ import { type ClassValue, clsx } from "clsx" import { twMerge } from "tailwind-merge" +import heic2any from "heic2any" export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)) @@ -38,7 +39,28 @@ export const compressImageBase64 = (base64Str: string, maxWidth = 800, quality = }; export const convertImageToWebpFile = async (file: File): Promise => { - return new Promise((resolve) => { + return new Promise(async (resolve) => { + let processableFile: File | Blob = file; + + // Convert HEIC/HEIF to JPEG first so the browser can read it + const isHeic = file.name.toLowerCase().endsWith('.heic') || file.name.toLowerCase().endsWith('.heif'); + if (isHeic) { + try { + const converted = await heic2any({ + blob: file, + toType: "image/jpeg", + quality: 0.8 // high quality intermediate step + }); + + // heic2any can return an array of blobs if it's an image sequence, just take the first + processableFile = Array.isArray(converted) ? converted[0] : converted; + } catch (err) { + console.error("Failed to convert HEIC image:", err); + // If conversion fails, just fall back to returning the original file + return resolve(file); + } + } + const reader = new FileReader(); reader.onload = (e) => { const img = new Image(); @@ -92,6 +114,6 @@ export const convertImageToWebpFile = async (file: File): Promise => { img.src = e.target?.result as string; }; reader.onerror = () => resolve(file); // Fallback - reader.readAsDataURL(file); + reader.readAsDataURL(processableFile); }); };