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);
});
};