diff --git a/.gitignore b/.gitignore index 831aefc..3a329a1 100644 --- a/.gitignore +++ b/.gitignore @@ -37,4 +37,5 @@ todo.txt /scripts/sync-db.js /scripts/update-env.js /scripts/update-env.js -/scripts/README.md \ No newline at end of file +/scripts/README.md +.vscode/ \ No newline at end of file diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 0967ef4..0000000 --- a/.vscode/settings.json +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/components/PanelPages/BlogEditorSection.tsx b/components/PanelPages/BlogEditorSection.tsx index 2cadf06..f63bd0b 100644 --- a/components/PanelPages/BlogEditorSection.tsx +++ b/components/PanelPages/BlogEditorSection.tsx @@ -1,1719 +1,12 @@ -import React, { useCallback, useEffect, useMemo, useRef, useState } from "react" +import React, { useEffect, useMemo, useRef, useState } from "react" import { Inter } from "next/font/google" -import { FiPlus, FiFileText, FiFilter, FiSearch, FiBold, FiItalic, FiLink, FiUnderline, FiImage, FiVideo, FiCornerUpLeft, FiCornerUpRight, FiList, FiAlignLeft, FiAlignCenter, FiAlignRight, FiEdit2, FiTrash2 } from "react-icons/fi" -import { RiArrowDropDownLine } from "react-icons/ri" +import { FiPlus, FiFileText, FiFilter, FiSearch, FiEdit2, FiTrash2 } from "react-icons/fi" import ConfirmActionModal from "@/components/ui/ConfirmActionModal" import RowActionMenu, { RowActionMenuItem } from "@/components/ui/RowActionMenu" +import RichTextEditor from "@/components/ui/RichTextEditor" const inter = Inter({ subsets: ["latin"] }) -const FONT_SIZE_OPTIONS = [8, 9, 10, 11, 12, 14, 16, 18, 24, 36, 48, 72] - -const normalizeUrl = (u: string) => { - const t = u.trim() - if (/^(https?:|data:|blob:)/i.test(t)) return t - if (t.startsWith('//')) return `https:${t}` - return `https://${t.replace(/^\/+/, '')}` -} - -const RichTextEditor: React.FC<{ - value: string - onChange: (value: string) => void -}> = ({ value, onChange }) => { - const editableRef = useRef(null) - const isLocalEditRef = useRef(false) - const savedRangeRef = useRef(null) - const selectedEmbedRef = useRef(null) - const [linkModalOpen, setLinkModalOpen] = useState(false) - const [linkText, setLinkText] = useState("") - const [linkUrl, setLinkUrl] = useState("") - const [imageModalOpen, setImageModalOpen] = useState(false) - const [imageUrl, setImageUrl] = useState("") - const [videoModalOpen, setVideoModalOpen] = useState(false) - const [videoUrl, setVideoUrl] = useState("") - - const setCaretToEnd = (el: HTMLElement) => { - try { - const sel = window.getSelection() - if (!sel) return - const range = document.createRange() - range.selectNodeContents(el) - range.collapse(false) - sel.removeAllRanges() - sel.addRange(range) - } catch {} - } - const [activeBold, setActiveBold] = useState(false) - const [activeItalic, setActiveItalic] = useState(false) - const [activeUnderline, setActiveUnderline] = useState(false) - const [activeAlign, setActiveAlign] = useState<"left" | "center" | "right" | "none">("none") - const [selectedFont, setSelectedFont] = useState("Inter") - const [selectedFontSize, setSelectedFontSize] = useState("12") - const [selectedTextColor, setSelectedTextColor] = useState("#111827") - const [selectedHighlightColor, setSelectedHighlightColor] = useState("#fff3a3") - const [fontSizeMenuOpen, setFontSizeMenuOpen] = useState(false) - const fontSizeMenuRef = useRef(null) - const fontSizeMap: Record = { - "8": "1", - "9": "1", - "10": "2", - "11": "2", - "12": "3", - "14": "4", - "16": "4", - "18": "5", - "24": "6", - "36": "7", - "48": "7", - "72": "7", - } - const fontSizeOptions = FONT_SIZE_OPTIONS - const allowFontSize = (size: string) => { - if (!size) return false - const n = parseInt(size, 10) - return Number.isFinite(n) && n >= 8 && n <= 72 - } - const applyFontSize = (size: string) => { - if (!allowFontSize(size)) return - setSelectedFontSize(size) - formatText("fontSize", size) - } - - const normalizeFontName = (family: string) => { - const f = family.toLowerCase() - if (f.includes("inter")) return "Inter" - if (f.includes("arial")) return "Arial" - if (f.includes("georgia")) return "Georgia" - if (f.includes("times new roman")) return "Times New Roman" - if (f.includes("courier new")) return "Courier New" - return "Inter" - } - - const getFontFromSelection = useCallback(() => { - try { - const cmd = document.queryCommandValue("fontName") - if (typeof cmd === "string" && cmd.trim()) { - return normalizeFontName(cmd) - } - } catch {} - try { - const el = editableRef.current - const sel = window.getSelection() - if (!el || !sel || !sel.anchorNode) return null - const node: HTMLElement | null = - sel.anchorNode.nodeType === Node.ELEMENT_NODE - ? (sel.anchorNode as HTMLElement) - : sel.anchorNode.parentElement - if (!node || !el.contains(node)) return null - const fontTag = node.closest("font") - const face = fontTag?.getAttribute("face") - if (face) return normalizeFontName(face) - const family = window.getComputedStyle(node).fontFamily || "" - return normalizeFontName(family) - } catch { - return null - } - }, []) - - const restoreSelection = () => { - try { - const sel = window.getSelection() - const range = savedRangeRef.current - if (!sel || !range) return - sel.removeAllRanges() - sel.addRange(range) - } catch {} - } - - const getFontSizeFromSelection = useCallback(() => { - try { - const el = editableRef.current - const sel = window.getSelection() - if (!el || !sel || !sel.anchorNode) return null - const node: HTMLElement | null = - sel.anchorNode.nodeType === Node.ELEMENT_NODE - ? (sel.anchorNode as HTMLElement) - : sel.anchorNode.parentElement - if (!node || !el.contains(node)) return null - const size = window.getComputedStyle(node).fontSize || "" - const px = parseFloat(size) - if (!px || Number.isNaN(px)) return null - const closest = FONT_SIZE_OPTIONS.reduce((prev, curr) => - Math.abs(curr - px) < Math.abs(prev - px) ? curr : prev - ) - return String(closest) - } catch { - return null - } - }, []) - - const getColorFromSelection = () => { - try { - const el = editableRef.current - const sel = window.getSelection() - if (!el || !sel || !sel.anchorNode) return null - const node: HTMLElement | null = - sel.anchorNode.nodeType === Node.ELEMENT_NODE - ? (sel.anchorNode as HTMLElement) - : sel.anchorNode.parentElement - if (!node || !el.contains(node)) return null - const color = window.getComputedStyle(node).color || "" - if (!color) return null - const match = color.match(/\d+/g) - if (!match || match.length < 3) return null - const toHex = (n: number) => n.toString(16).padStart(2, "0") - const [r, g, b] = match.slice(0, 3).map((v) => Math.min(255, Math.max(0, parseInt(v, 10)))) - return `#${toHex(r)}${toHex(g)}${toHex(b)}` - } catch { - return null - } - } - - const getHighlightFromSelection = () => { - try { - const el = editableRef.current - const sel = window.getSelection() - if (!el || !sel || !sel.anchorNode) return null - const node: HTMLElement | null = - sel.anchorNode.nodeType === Node.ELEMENT_NODE - ? (sel.anchorNode as HTMLElement) - : sel.anchorNode.parentElement - if (!node || !el.contains(node)) return null - const bg = window.getComputedStyle(node).backgroundColor || "" - if (!bg || bg === "transparent") return null - const match = bg.match(/\d+/g) - if (!match || match.length < 3) return null - const toHex = (n: number) => n.toString(16).padStart(2, "0") - const [r, g, b] = match.slice(0, 3).map((v) => Math.min(255, Math.max(0, parseInt(v, 10)))) - return `#${toHex(r)}${toHex(g)}${toHex(b)}` - } catch { - return null - } - } - - useEffect(() => { - const updateStates = () => { - try { - setActiveBold(document.queryCommandState('bold')) - setActiveItalic(document.queryCommandState('italic')) - setActiveUnderline(document.queryCommandState('underline')) - if (document.queryCommandState("justifyCenter")) { - setActiveAlign("center") - } else if (document.queryCommandState("justifyRight")) { - setActiveAlign("right") - } else if (document.queryCommandState("justifyLeft")) { - setActiveAlign("left") - } else { - setActiveAlign("none") - } - } catch {} - try { - const el = editableRef.current - const sel = window.getSelection() - if (el && sel && sel.rangeCount > 0 && sel.anchorNode && el.contains(sel.anchorNode)) { - savedRangeRef.current = sel.getRangeAt(0).cloneRange() - } - } catch {} - const detected = getFontFromSelection() - if (detected) setSelectedFont(detected) - const detectedSize = getFontSizeFromSelection() - if (detectedSize) setSelectedFontSize(detectedSize) - const detectedColor = getColorFromSelection() - if (detectedColor) setSelectedTextColor(detectedColor) - const detectedHighlight = getHighlightFromSelection() - if (detectedHighlight) setSelectedHighlightColor(detectedHighlight) - } - document.addEventListener('selectionchange', updateStates) - return () => document.removeEventListener('selectionchange', updateStates) - }, [getFontFromSelection, getFontSizeFromSelection]) - const isResizingRef = useRef(false) - const resizeStartRef = useRef<{ x: number; width: number; isLeftSide: boolean } | null>(null) - const handleResizeStartRef = useRef<((e: MouseEvent, embed: HTMLElement) => void) | null>(null) - const findEmbed = useCallback((target: HTMLElement): HTMLElement | null => { - if (!target) return null - if (target.classList?.contains('rte-embed')) { - return target - } - if (target.classList?.contains('rte-media') || target.tagName === 'IMG' || target.tagName === 'IFRAME' || target.tagName === 'VIDEO') { - return target.closest('.rte-embed') as HTMLElement | null - } - if (target.classList?.contains('rte-resize-handle')) { - return target.closest('.rte-embed') as HTMLElement | null - } - return target.closest('.rte-embed') as HTMLElement | null - }, []) - const selectEmbed = useCallback((embed: HTMLElement) => { - if (selectedEmbedRef.current && selectedEmbedRef.current !== embed) { - selectedEmbedRef.current.removeAttribute("data-selected") - selectedEmbedRef.current.querySelectorAll('.rte-resize-handle').forEach(h => h.remove()) - } - embed.setAttribute("data-selected", "true") - selectedEmbedRef.current = embed - const align = embed.getAttribute("data-align") - if (align === "center" || align === "right" || align === "left") { - setActiveAlign(align) - } else { - setActiveAlign("left") - } - const isImage = embed.getAttribute("data-type") === "image" - if (isImage && !embed.querySelector('.rte-resize-handle')) { - const corners = ['nw', 'ne', 'sw', 'se'] - corners.forEach(corner => { - const handle = document.createElement('div') - handle.className = `rte-resize-handle rte-resize-handle-${corner}` - handle.setAttribute('contenteditable', 'false') - handle.setAttribute('data-corner', corner) - embed.appendChild(handle) - }) - } - }, []) - const clearEmbedSelection = useCallback(() => { - if (selectedEmbedRef.current) { - selectedEmbedRef.current.removeAttribute("data-selected") - selectedEmbedRef.current.querySelectorAll('.rte-resize-handle').forEach(h => h.remove()) - selectedEmbedRef.current = null - setActiveAlign("none") - } - }, []) - handleResizeStartRef.current = (e: MouseEvent, embed: HTMLElement) => { - e.preventDefault() - e.stopPropagation() - isResizingRef.current = true - const media = embed.querySelector('.rte-media') as HTMLImageElement - const currentWidth = media?.naturalWidth ? media.offsetWidth : (media?.offsetWidth || 300) - const handle = e.target as HTMLElement - const corner = handle.getAttribute('data-corner') || 'se' - const isLeftSide = corner === 'nw' || corner === 'sw' - const startData = { x: e.clientX, width: currentWidth, isLeftSide } - resizeStartRef.current = startData - - const handleResizeMove = (moveEvent: MouseEvent) => { - if (!isResizingRef.current || !resizeStartRef.current || !selectedEmbedRef.current) return - let delta = moveEvent.clientX - resizeStartRef.current.x - if (resizeStartRef.current.isLeftSide) { - delta = -delta - } - const newWidth = Math.max(50, Math.min(800, resizeStartRef.current.width + delta)) - const mediaEl = selectedEmbedRef.current.querySelector('.rte-media') as HTMLElement - if (mediaEl) { - mediaEl.style.width = `${newWidth}px` - mediaEl.style.height = 'auto' - } - selectedEmbedRef.current.setAttribute('data-width', String(newWidth)) - } - - const handleResizeEnd = () => { - isResizingRef.current = false - resizeStartRef.current = null - document.removeEventListener('mousemove', handleResizeMove) - document.removeEventListener('mouseup', handleResizeEnd) - if (editableRef.current) { - editableRef.current.dispatchEvent(new Event('input', { bubbles: true })) - } - } - - document.addEventListener('mousemove', handleResizeMove) - document.addEventListener('mouseup', handleResizeEnd) - } - useEffect(() => { - const el = editableRef.current - if (!el) return - - const onMouseDown = (e: MouseEvent) => { - const t = e.target as HTMLElement - if (!t) return - if (t.classList?.contains('rte-resize-handle')) { - const embed = t.closest('.rte-embed') as HTMLElement - if (embed && handleResizeStartRef.current) { - handleResizeStartRef.current(e, embed) - return - } - } - - const embed = findEmbed(t) - if (embed && el.contains(embed)) { - e.preventDefault() - e.stopPropagation() - selectEmbed(embed) - void embed.offsetHeight - } - } - - const onClick = (e: MouseEvent) => { - const t = e.target as HTMLElement - if (!t) return - if (t.tagName === 'A') { - e.preventDefault() - } - const embed = findEmbed(t) - if (!embed && selectedEmbedRef.current) { - clearEmbedSelection() - } - } - - el.addEventListener('mousedown', onMouseDown, true) - el.addEventListener('click', onClick, true) - return () => { - el.removeEventListener('mousedown', onMouseDown, true) - el.removeEventListener('click', onClick, true) - } - }, [findEmbed, selectEmbed, clearEmbedSelection]) - const [history, setHistory] = useState([value]) - const [historyIndex, setHistoryIndex] = useState(0) - - const toYoutubeEmbed = useCallback((u: string): string | null => { - const url = normalizeUrl(u) - try { - const a = new URL(url) - if (a.hostname.includes('youtu.be')) { - const id = a.pathname.replace(/^\//, '') - if (id) return `https://www.youtube.com/embed/${id}` - } - if (a.hostname.includes('youtube.com')) { - if (a.pathname.startsWith('/watch')) { - const id = a.searchParams.get('v') - if (id) return `https://www.youtube.com/embed/${id}` - } - if (a.pathname.startsWith('/shorts/')) { - const id = a.pathname.split('/')[2] - if (id) return `https://www.youtube.com/embed/${id}` - } - } - } catch {} - return null - }, []) - const buildVideoEmbedHtml = useCallback((u: string) => { - const yt = toYoutubeEmbed(u) - if (yt) { - return `` - } - return `` - }, [toYoutubeEmbed]) - const buildImageEmbedHtml = (u: string) => `` - const toDisplay = useCallback((html: string): string => { - if (!html) return "" - let out = html - out = out.replace(/(]*)\s*class="[^"]*rte-media[^"]*"/gi, '$1') - out = out.replace(/]*style="[^"]*text-align:(left|center|right)[^"]*"[^>]*>[\s\S]*?]*)>[\s\S]*?<\/div>/gi, (match, align, imgAttrs) => { - const srcMatch = imgAttrs.match(/src="([^"]+)"/) - const widthMatch = imgAttrs.match(/style="[^"]*width:\s*(\d+)px/) - const url = srcMatch ? srcMatch[1] : '' - if (!url) return match - const width = widthMatch ? widthMatch[1] : '' - const widthAttr = width ? ` data-width="${width}"` : '' - const widthStyle = width ? ` style="width:${width}px;"` : '' - const embed = `` - return `
${embed}
` - }) - out = out.replace(/]*style="[^"]*text-align:(left|center|right)[^"]*"[^>]*>[\s\S]*?<(iframe|video)[^>]*>[\s\S]*?<\/(iframe|video)>[\s\S]*?<\/div>/gi, (_m, align) => { - const srcMatch = _m.match(/src="([^"]+)"/) - if (srcMatch) { - const url = srcMatch[1] - const embed = buildVideoEmbedHtml(url) - return `
${embed}
` - } - return _m - }) - out = out.replace(/]*)>/gi, (match, attrs) => { - if (attrs.includes('rte-media')) return match - const srcMatch = attrs.match(/src="([^"]+)"/) - const widthMatch = attrs.match(/width:\s*(\d+)px/) - const url = srcMatch ? srcMatch[1] : '' - if (!url) return match - const width = widthMatch ? widthMatch[1] : '' - const widthAttr = width ? ` data-width="${width}"` : '' - const widthStyle = width ? ` style="width:${width}px;"` : '' - const embed = `` - return `
${embed}
` - }) - out = out.replace(/]*)>[\s\S]*?<\/iframe>/gi, (match, attrs) => { - if (attrs.includes('rte-media')) return match - const srcMatch = attrs.match(/src="([^"]+)"/) - const url = srcMatch ? srcMatch[1] : '' - if (!url) return match - const embed = buildVideoEmbedHtml(url) - return `
${embed}
` - }) - out = out.replace(/]*)>[\s\S]*?<\/video>/gi, (match, attrs) => { - if (attrs.includes('rte-media')) return match - const srcMatch = attrs.match(/src="([^"]+)"/) - const url = srcMatch ? srcMatch[1] : '' - if (!url) return match - const embed = buildVideoEmbedHtml(url) - return `
${embed}
` - }) - out = out.replace(/]*>[\s\S]*?]*src="([^"]+)"[^>]*>[\s\S]*?<\/video>/gi, (_m, url) => { - const embed = buildVideoEmbedHtml(url) - return `
${embed}
` - }) - out = out.replace(/]*href="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, href, text) => { - const url = normalizeUrl(href) - return `${text}` - }) - return out - }, [buildVideoEmbedHtml]) - const toHtml = useCallback((display: string): string => { - if (!display) return "" - let out = display - out = out.replace(/]*class="rte-resize-handle[^"]*"[^>]*>[\s\S]*?<\/div>/gi, '') - out = out.replace(/]*\bclass="[^"]*rte-embed[^"]*"[^>]*>[\s\S]*?<\/div>/gi, (match) => { - if (!match.includes('data-type="image"')) { - if (match.includes('data-type="video"')) { - const urlMatch = match.match(/data-url="([^"]+)"/) - const alignMatch = match.match(/data-align="([^"]+)"/) - const url = urlMatch ? urlMatch[1] : '' - const align = alignMatch ? alignMatch[1] : '' - if (!url) return match - let mediaStyles = 'display:block;' - if (align === 'center') { - mediaStyles += 'margin-left:auto;margin-right:auto;' - } else if (align === 'right') { - mediaStyles += 'margin-left:auto;margin-right:0;' - } else if (align === 'left') { - mediaStyles += 'margin-left:0;margin-right:auto;' - } - const yt = toYoutubeEmbed(url) - let media: string - if (yt) { - media = `` - } else { - media = `` - } - if (align && (align === 'left' || align === 'center' || align === 'right')) { - return `
${media}
` - } - return media - } - return match - } - const urlMatch = match.match(/data-url="([^"]+)"/) - const alignMatch = match.match(/data-align="([^"]+)"/) - const widthMatch = match.match(/data-width="([^"]+)"/) - const url = urlMatch ? urlMatch[1] : '' - const align = alignMatch ? alignMatch[1] : '' - const width = widthMatch ? widthMatch[1] : '' - if (!url) return match - let imgStyles = 'display:block;' - if (width) imgStyles += `width:${width}px;` - if (align === 'center') { - imgStyles += 'margin-left:auto;margin-right:auto;' - } else if (align === 'right') { - imgStyles += 'margin-left:auto;margin-right:0;' - } else if (align === 'left') { - imgStyles += 'margin-left:0;margin-right:auto;' - } - const media = `` - if (align && (align === 'left' || align === 'center' || align === 'right')) { - return `
${media}
` - } - return media - }) - out = out.replace(/]*data-url="([^"]+)"[^>]*>([\s\S]*?)<\/a>/gi, (_m, url, text) => `${text}<\/a>`) - out = out.replace(/]*data-type="link"[^>]*data-url="([^"]+)"[^>]*>.*?<\/span>/gi, (_m, url) => `${normalizeUrl(url)}<\/a>`) - return out - }, [toYoutubeEmbed]) - - const setupEmbedHandlers = useCallback((embed: HTMLElement) => { - if (embed.hasAttribute('data-initialized')) return - - embed.setAttribute('data-initialized', 'true') - embed.setAttribute('draggable', 'true') - - const handleDragStart = (e: DragEvent) => { - embed.setAttribute('data-dragging', 'true') - if (e.dataTransfer) { - e.dataTransfer.effectAllowed = 'move' - e.dataTransfer.setData('text/html', embed.outerHTML) - } - } - const handleDragEnd = () => { - embed.removeAttribute('data-dragging') - } - - embed.addEventListener('dragstart', handleDragStart) - embed.addEventListener('dragend', handleDragEnd) - }, []) - - const undo = () => { - if (historyIndex > 0) { - const newIndex = historyIndex - 1 - setHistoryIndex(newIndex) - onChange(history[newIndex]) - } - } - - const redo = () => { - if (historyIndex < history.length - 1) { - const newIndex = historyIndex + 1 - setHistoryIndex(newIndex) - onChange(history[newIndex]) - } - } - const lastSyncedValueRef = useRef('') - useEffect(() => { - if (!isLocalEditRef.current) { - if (value !== lastSyncedValueRef.current) { - const disp = toDisplay(value || "") - if (editableRef.current) { - editableRef.current.innerHTML = disp - setCaretToEnd(editableRef.current) - } - lastSyncedValueRef.current = value - } - } else { - isLocalEditRef.current = false - lastSyncedValueRef.current = value - } - if (value !== history[historyIndex]) { - const newHistory = history.slice(0, historyIndex + 1) - newHistory.push(value) - setHistory(newHistory) - setHistoryIndex(newHistory.length - 1) - } - const el = editableRef.current - if (el) { - requestAnimationFrame(() => { - const embeds = el.querySelectorAll('.rte-embed:not([data-initialized])') - embeds.forEach((embed) => { - setupEmbedHandlers(embed as HTMLElement) - }) - }) - } - }, [value, history, historyIndex, toDisplay, setupEmbedHandlers]) - const uploadBlogImage = async (file: File): Promise => { - try { - const base64 = await new Promise((resolve, reject) => { - const reader = new FileReader() - reader.onloadend = () => resolve((reader.result as string).split(',')[1]) - reader.onerror = reject - reader.readAsDataURL(file) - }) - const resp = await fetch('/api/blog/admin/uploadImage', { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ imageData: base64, mimeType: file.type, filename: file.name }), - }) - if (!resp.ok) { - console.error('Image upload failed:', resp.status) - return null - } - const data = await resp.json() - return data.url - } catch (err) { - console.error('Image upload error:', err) - return null - } - } - - const insertPlaceholder = (type: 'image' | 'video', url: string) => { - const el = editableRef.current - if (!el) return - const sel = window.getSelection() - if (!sel) return - if (type === "video") { - const range = savedRangeRef.current || (sel.rangeCount > 0 ? sel.getRangeAt(0) : null) - const embed = document.createElement("div") - embed.setAttribute("data-type", "video") - embed.setAttribute("data-url", url) - embed.setAttribute("contenteditable", "false") - embed.className = "rte-embed" - embed.innerHTML = buildVideoEmbedHtml(url) - setupEmbedHandlers(embed) - if (range) { - range.deleteContents() - range.insertNode(embed) - range.setStartAfter(embed) - range.collapse(true) - sel.removeAllRanges() - sel.addRange(range) - } else { - el.appendChild(embed) - } - isLocalEditRef.current = true - const newDisplay = el.innerHTML - onChange(toHtml(newDisplay)) - return - } - if (type === "image") { - const range = savedRangeRef.current || (sel.rangeCount > 0 ? sel.getRangeAt(0) : null) - const embed = document.createElement("div") - embed.setAttribute("data-type", "image") - embed.setAttribute("data-url", url) - embed.setAttribute("contenteditable", "false") - embed.className = "rte-embed" - embed.innerHTML = buildImageEmbedHtml(url) - setupEmbedHandlers(embed) - if (range) { - range.deleteContents() - range.insertNode(embed) - range.setStartAfter(embed) - range.collapse(true) - sel.removeAllRanges() - sel.addRange(range) - } else { - el.appendChild(embed) - } - isLocalEditRef.current = true - const newDisplay = el.innerHTML - onChange(toHtml(newDisplay)) - return - } - } - - const formatText = (format: string, value?: string) => { - const el = editableRef.current - if (!el) return - el.focus() - const sel = window.getSelection() - if (!sel) return - switch (format) { - case 'font': - if (sel.isCollapsed) { - const range = document.createRange() - range.selectNodeContents(el) - sel.removeAllRanges() - sel.addRange(range) - document.execCommand('fontName', false, value || selectedFont) - range.collapse(false) - sel.removeAllRanges() - sel.addRange(range) - } else { - document.execCommand('fontName', false, value || selectedFont) - } - break - case 'fontSize': { - const sizePx = value || "12" - const sizeCmd = fontSizeMap[sizePx] || "3" - restoreSelection() - if (sel.isCollapsed) { - const range = document.createRange() - range.selectNodeContents(el) - sel.removeAllRanges() - sel.addRange(range) - document.execCommand('fontSize', false, sizeCmd) - range.collapse(false) - sel.removeAllRanges() - sel.addRange(range) - } else { - document.execCommand('fontSize', false, sizeCmd) - } - const fontEls = el.querySelectorAll("font[size]") - fontEls.forEach((fontEl) => { - fontEl.removeAttribute("size") - fontEl.setAttribute("style", `font-size: ${sizePx}px;`) - }) - setSelectedFontSize(sizePx) - break - } - case 'textColor': - restoreSelection() - document.execCommand('foreColor', false, value || selectedTextColor) - break - case 'highlight': - restoreSelection() - document.execCommand('hiliteColor', false, value || selectedHighlightColor) - break - case 'bold': - document.execCommand('bold') - break - case 'italic': - document.execCommand('italic') - break - case 'underline': - document.execCommand('underline') - break - case 'alignLeft': - case 'alignCenter': - case 'alignRight': - { - const alignValue = format === 'alignLeft' ? 'left' : format === 'alignCenter' ? 'center' : 'right' - const embed = selectedEmbedRef.current - if (embed && embed.classList.contains('rte-embed')) { - embed.setAttribute('data-align', alignValue) - void embed.offsetHeight - setActiveAlign(alignValue) - embed.setAttribute('data-selected', 'true') - isLocalEditRef.current = true - const newDisplay = el.innerHTML - onChange(toHtml(newDisplay)) - return - } else { - const cmd = format === 'alignLeft' ? 'justifyLeft' : format === 'alignCenter' ? 'justifyCenter' : 'justifyRight' - document.execCommand(cmd) - } - } - break - case 'list': - restoreSelection() - if (el) el.focus() - { - const selectedText = sel.toString() - const range = sel.rangeCount > 0 ? sel.getRangeAt(0) : null - - if (selectedText.trim() || (range && selectedText.includes('\n'))) { - const lines = selectedText.split(/\r?\n/) - const ul = document.createElement('ul') - ul.style.listStyleType = 'disc' - ul.style.paddingLeft = '24px' - ul.style.margin = '8px 0' - - lines.forEach((line) => { - const trimmed = line.trim() - const li = document.createElement('li') - li.style.display = 'list-item' - if (trimmed) { - li.textContent = trimmed - } else { - li.innerHTML = '
' - } - ul.appendChild(li) - }) - - if (range) { - range.deleteContents() - range.insertNode(ul) - const newRange = document.createRange() - newRange.setStartAfter(ul.lastChild as Node) - newRange.collapse(true) - sel.removeAllRanges() - sel.addRange(newRange) - } else if (el) { - el.appendChild(ul) - } - const newDisplay = el.innerHTML - onChange(toHtml(newDisplay)) - return - } else { - if (range && range.collapsed && !selectedText.trim()) { - const ul = document.createElement('ul') - ul.style.listStyleType = 'disc' - ul.style.paddingLeft = '24px' - ul.style.margin = '8px 0' - const li = document.createElement('li') - li.style.display = 'list-item' - li.innerHTML = '
' - ul.appendChild(li) - const container = range.startContainer - const parent = container.nodeType === Node.TEXT_NODE ? container.parentElement : container as HTMLElement - - if (parent && (parent.tagName === 'P' || parent.tagName === 'DIV')) { - if (!parent.textContent?.trim() && parent.children.length === 0) { - parent.parentNode?.replaceChild(ul, parent) - } else { - if (range.startOffset === 0 && !parent.textContent?.trim()) { - parent.parentNode?.insertBefore(ul, parent) - } else { - range.insertNode(ul) - } - } - } else { - range.insertNode(ul) - } - - const newRange = document.createRange() - newRange.setStart(li, 0) - newRange.collapse(true) - sel.removeAllRanges() - sel.addRange(newRange) - - const newDisplay = el.innerHTML - onChange(toHtml(newDisplay)) - return - } - const before = el?.innerHTML || "" - const ok = document.execCommand('insertUnorderedList') - const after = el?.innerHTML || "" - - if (!ok || before === after) { - const ul = document.createElement('ul') - ul.style.listStyleType = 'disc' - ul.style.paddingLeft = '24px' - ul.style.margin = '8px 0' - const li = document.createElement('li') - li.style.display = 'list-item' - - if (range) { - const contents = range.extractContents() - if (contents.textContent?.trim() || contents.childNodes.length > 0) { - li.appendChild(contents) - } else { - li.innerHTML = '
' - } - ul.appendChild(li) - range.insertNode(ul) - const newRange = document.createRange() - newRange.setStart(li, 0) - newRange.collapse(true) - sel.removeAllRanges() - sel.addRange(newRange) - } else if (el) { - li.innerHTML = '
' - ul.appendChild(li) - el.appendChild(ul) - const newRange = document.createRange() - newRange.setStart(li, 0) - newRange.collapse(true) - const sel = window.getSelection() - if (sel) { - sel.removeAllRanges() - sel.addRange(newRange) - } - } - const newDisplay = el.innerHTML - onChange(toHtml(newDisplay)) - return - } - } - } - break - case 'link': { - const selText = sel.toString() - savedRangeRef.current = sel.getRangeAt(0).cloneRange() - setLinkText(selText || "") - setLinkUrl("") - setLinkModalOpen(true) - return - } - case 'image': { - setImageUrl("") - setImageModalOpen(true) - return - } - case 'video': { - setVideoUrl("") - setVideoModalOpen(true) - return - } - } - const newDisplay = el.innerHTML - onChange(toHtml(newDisplay)) - } - - const cycleAlignment = () => { - const current = activeAlign === "none" ? "left" : activeAlign - const next = current === "left" ? "center" : current === "center" ? "right" : "left" - const command = next === "left" ? "alignLeft" : next === "center" ? "alignCenter" : "alignRight" - if (selectedEmbedRef.current) { - const embed = selectedEmbedRef.current - embed.setAttribute('data-align', next) - void embed.offsetHeight - setActiveAlign(next) - embed.setAttribute('data-selected', 'true') - const el = editableRef.current - if (el) { - isLocalEditRef.current = true - onChange(toHtml(el.innerHTML)) - } - } else { - formatText(command) - setActiveAlign(next) - } - } - - const handleDragOver = (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - const el = editableRef.current - if (!el) return - const target = e.target as HTMLElement - if (target.closest('.rte-embed[data-dragging="true"]')) { - return - } - const dropTarget = target.closest('.rte-embed') || target - if (dropTarget && dropTarget !== el) { - const rect = dropTarget.getBoundingClientRect() - const y = e.clientY - rect.top - if (y < rect.height / 2) { - dropTarget.setAttribute('data-drop-before', 'true') - dropTarget.removeAttribute('data-drop-after') - } else { - dropTarget.setAttribute('data-drop-after', 'true') - dropTarget.removeAttribute('data-drop-before') - } - } - } - - const handleDrop = async (e: React.DragEvent) => { - e.preventDefault() - e.stopPropagation() - const el = editableRef.current - if (!el) return - - const files = Array.from(e.dataTransfer.files) - if (files.length > 0) { - el.focus() - for (const file of files) { - if (file.type.startsWith('image/')) { - const permanentUrl = await uploadBlogImage(file) - if (permanentUrl) { - insertPlaceholder('image', permanentUrl) - } - } else if (file.type.startsWith('video/')) { - const url = URL.createObjectURL(file) - insertPlaceholder('video', url) - } - } - return - } - - const draggedHtml = e.dataTransfer.getData('text/html') - if (draggedHtml) { - const temp = document.createElement('div') - temp.innerHTML = draggedHtml - const draggedEmbed = temp.querySelector('.rte-embed') as HTMLElement - if (draggedEmbed) { - const existingEmbed = el.querySelector('.rte-embed[data-dragging="true"]') as HTMLElement - const target = (e.target as HTMLElement).closest('.rte-embed') as HTMLElement - if (existingEmbed && target && existingEmbed !== target) { - const range = document.createRange() - const sel = window.getSelection() - const newEmbed = draggedEmbed.cloneNode(true) as HTMLElement - newEmbed.removeAttribute('data-initialized') - newEmbed.removeAttribute('data-dragging') - setupEmbedHandlers(newEmbed) - if (target.hasAttribute('data-drop-before')) { - range.setStartBefore(target) - range.insertNode(newEmbed) - } else { - range.setStartAfter(target) - range.insertNode(newEmbed) - } - existingEmbed.remove() - target.removeAttribute('data-drop-before') - target.removeAttribute('data-drop-after') - if (sel) { - range.setStartAfter(newEmbed) - range.collapse(true) - sel.removeAllRanges() - sel.addRange(range) - } - isLocalEditRef.current = true - const newDisplay = el.innerHTML - onChange(toHtml(newDisplay)) - } - } - } - } - - return ( -
- -
- - -
-
- - -
-
{ - if (!e.currentTarget.contains(e.relatedTarget as Node)) { - setFontSizeMenuOpen(false) - } - }} - tabIndex={-1} - > - { - const raw = event.target.value - const cleaned = raw.replace(/[^\d]/g, "").slice(0, 2) - setSelectedFontSize(cleaned) - }} - onFocus={() => setFontSizeMenuOpen(true)} - onClick={() => setFontSizeMenuOpen(true)} - onBlur={() => { - if (!selectedFontSize) { - setSelectedFontSize("12") - return - } - applyFontSize(selectedFontSize) - }} - onKeyDown={(event) => { - if (event.key === "Enter") { - event.preventDefault() - applyFontSize(selectedFontSize) - setFontSizeMenuOpen(false) - ;(event.currentTarget as HTMLInputElement).blur() - } - }} - className="text-sm w-12 border border-gray-300 rounded-md bg-white pl-2 pr-5 py-1 text-gray-700 text-center focus:outline-none focus:ring-2 focus:ring-[#701CC0]" - title="Text size" - inputMode="numeric" - maxLength={2} - /> - - {fontSizeMenuOpen && ( -
- {fontSizeOptions.map((size) => ( - - ))} -
- )} -
- - - - - - - - - - -
- - -
- -
{ - const navKeys = ['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'Tab', 'Shift', 'Control', 'Alt', 'Meta'] - if (navKeys.includes(e.key)) return - - if (selectedEmbedRef.current) { - selectedEmbedRef.current.removeAttribute("data-selected") - selectedEmbedRef.current = null - setActiveAlign("none") - } - }} - onInput={() => { - if (!editableRef.current) return - const newDisplay = editableRef.current.innerHTML - isLocalEditRef.current = true - onChange(toHtml(newDisplay)) - requestAnimationFrame(() => { - const embeds = editableRef.current?.querySelectorAll('.rte-embed:not([data-initialized])') - embeds?.forEach((embed) => { - setupEmbedHandlers(embed as HTMLElement) - }) - }) - }} - /> - - - -