HEIC support
This commit is contained in:
@@ -32,6 +32,7 @@
|
|||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"lucide-solid": "^0.563.0",
|
"lucide-solid": "^0.563.0",
|
||||||
"pm2": "^6.0.14",
|
"pm2": "^6.0.14",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
@@ -825,6 +826,8 @@
|
|||||||
|
|
||||||
"hasown": ["hasown@2.0.2", "", { "dependencies": { "function-bind": "^1.1.2" } }, "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ=="],
|
"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=="],
|
"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=="],
|
"http-proxy-agent": ["http-proxy-agent@7.0.2", "", { "dependencies": { "agent-base": "^7.1.0", "debug": "^4.3.4" } }, "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig=="],
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
"autoprefixer": "^10.4.23",
|
"autoprefixer": "^10.4.23",
|
||||||
"class-variance-authority": "^0.7.1",
|
"class-variance-authority": "^0.7.1",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
|
"heic2any": "^0.0.4",
|
||||||
"lucide-solid": "^0.563.0",
|
"lucide-solid": "^0.563.0",
|
||||||
"pm2": "^6.0.14",
|
"pm2": "^6.0.14",
|
||||||
"pocketbase": "^0.26.8",
|
"pocketbase": "^0.26.8",
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import StarterKit from "@tiptap/starter-kit";
|
|||||||
import Placeholder from "@tiptap/extension-placeholder";
|
import Placeholder from "@tiptap/extension-placeholder";
|
||||||
import TaskList from "@tiptap/extension-task-list";
|
import TaskList from "@tiptap/extension-task-list";
|
||||||
import { CustomTaskItem } from "./CustomTaskItem";
|
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 Underline from "@tiptap/extension-underline";
|
||||||
import { X } from "lucide-solid";
|
import { X } from "lucide-solid";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ export const ImageUpload = Extension.create({
|
|||||||
|
|
||||||
const input = document.createElement("input");
|
const input = document.createElement("input");
|
||||||
input.type = "file";
|
input.type = "file";
|
||||||
input.accept = "image/*";
|
input.accept = "image/*,.heic,.heif";
|
||||||
input.onchange = async () => {
|
input.onchange = async () => {
|
||||||
if (input.files?.length) {
|
if (input.files?.length) {
|
||||||
const originalFile = input.files[0];
|
const originalFile = input.files[0];
|
||||||
|
|||||||
@@ -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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
};
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -42,6 +42,80 @@ export const Video = Node.create<VideoOptions>({
|
|||||||
return ['video', mergeAttributes(this.options.HTMLAttributes, HTMLAttributes)];
|
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 = '<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" class="text-muted-foreground hover:text-destructive transition-colors"><path d="M3 6h18"></path><path d="M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6"></path><path d="M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2"></path></svg>';
|
||||||
|
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() {
|
addCommands() {
|
||||||
return {
|
return {
|
||||||
setVideo: (options: { src: string }) => ({ commands }) => {
|
setVideo: (options: { src: string }) => ({ commands }) => {
|
||||||
|
|||||||
+24
-2
@@ -1,5 +1,6 @@
|
|||||||
import { type ClassValue, clsx } from "clsx"
|
import { type ClassValue, clsx } from "clsx"
|
||||||
import { twMerge } from "tailwind-merge"
|
import { twMerge } from "tailwind-merge"
|
||||||
|
import heic2any from "heic2any"
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
export function cn(...inputs: ClassValue[]) {
|
||||||
return twMerge(clsx(inputs))
|
return twMerge(clsx(inputs))
|
||||||
@@ -38,7 +39,28 @@ export const compressImageBase64 = (base64Str: string, maxWidth = 800, quality =
|
|||||||
};
|
};
|
||||||
|
|
||||||
export const convertImageToWebpFile = async (file: File): Promise<File> => {
|
export const convertImageToWebpFile = async (file: File): Promise<File> => {
|
||||||
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();
|
const reader = new FileReader();
|
||||||
reader.onload = (e) => {
|
reader.onload = (e) => {
|
||||||
const img = new Image();
|
const img = new Image();
|
||||||
@@ -92,6 +114,6 @@ export const convertImageToWebpFile = async (file: File): Promise<File> => {
|
|||||||
img.src = e.target?.result as string;
|
img.src = e.target?.result as string;
|
||||||
};
|
};
|
||||||
reader.onerror = () => resolve(file); // Fallback
|
reader.onerror = () => resolve(file); // Fallback
|
||||||
reader.readAsDataURL(file);
|
reader.readAsDataURL(processableFile);
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
Reference in New Issue
Block a user