UNPKG

pm-react-text-editor

Version:

A customizable and lightweight rich-text editor for React, built with hooks and modern styling. Supports common formatting tools like bold, italic, lists, headings, links, and more.

554 lines (489 loc) 22 kB
import { jsx, jsxs } from 'react/jsx-runtime'; import { forwardRef, createElement, createContext, useState, useRef, useEffect, useContext, useLayoutEffect } from 'react'; function ToolbarGroup({ children }) { return (jsx("div", { className: "toolbar-group", children: children })); } function ToolbarButton({ icon, tooltip, onClick, active }) { return (jsx("button", { type: "button", className: `toolbar-button ${active ? 'active' : ''}`, onClick: onClick, title: tooltip, children: icon })); } /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ var defaultAttributes = { xmlns: "http://www.w3.org/2000/svg", width: 24, height: 24, viewBox: "0 0 24 24", fill: "none", stroke: "currentColor", strokeWidth: 2, strokeLinecap: "round", strokeLinejoin: "round" }; /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const toKebabCase = (string) => string.replace(/([a-z0-9])([A-Z])/g, "$1-$2").toLowerCase().trim(); const createLucideIcon = (iconName, iconNode) => { const Component = forwardRef( ({ color = "currentColor", size = 24, strokeWidth = 2, absoluteStrokeWidth, className = "", children, ...rest }, ref) => { return createElement( "svg", { ref, ...defaultAttributes, width: size, height: size, stroke: color, strokeWidth: absoluteStrokeWidth ? Number(strokeWidth) * 24 / Number(size) : strokeWidth, className: ["lucide", `lucide-${toKebabCase(iconName)}`, className].join(" "), ...rest }, [ ...iconNode.map(([tag, attrs]) => createElement(tag, attrs)), ...Array.isArray(children) ? children : [children] ] ); } ); Component.displayName = `${iconName}`; return Component; }; /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const AlignCenter = createLucideIcon("AlignCenter", [ ["line", { x1: "21", x2: "3", y1: "6", y2: "6", key: "1fp77t" }], ["line", { x1: "17", x2: "7", y1: "12", y2: "12", key: "rsh8ii" }], ["line", { x1: "19", x2: "5", y1: "18", y2: "18", key: "1t0tuv" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const AlignJustify = createLucideIcon("AlignJustify", [ ["line", { x1: "3", x2: "21", y1: "6", y2: "6", key: "4m8b97" }], ["line", { x1: "3", x2: "21", y1: "12", y2: "12", key: "10d38w" }], ["line", { x1: "3", x2: "21", y1: "18", y2: "18", key: "kwyyxn" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const AlignLeft = createLucideIcon("AlignLeft", [ ["line", { x1: "21", x2: "3", y1: "6", y2: "6", key: "1fp77t" }], ["line", { x1: "15", x2: "3", y1: "12", y2: "12", key: "v6grx8" }], ["line", { x1: "17", x2: "3", y1: "18", y2: "18", key: "1awlsn" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const AlignRight = createLucideIcon("AlignRight", [ ["line", { x1: "21", x2: "3", y1: "6", y2: "6", key: "1fp77t" }], ["line", { x1: "21", x2: "9", y1: "12", y2: "12", key: "1uyos4" }], ["line", { x1: "21", x2: "7", y1: "18", y2: "18", key: "1g9eri" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const Bold = createLucideIcon("Bold", [ ["path", { d: "M14 12a4 4 0 0 0 0-8H6v8", key: "v2sylx" }], ["path", { d: "M15 20a4 4 0 0 0 0-8H6v8Z", key: "1ef5ya" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const ChevronDown = createLucideIcon("ChevronDown", [ ["path", { d: "m6 9 6 6 6-6", key: "qrunsl" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const Image = createLucideIcon("Image", [ ["rect", { width: "18", height: "18", x: "3", y: "3", rx: "2", ry: "2", key: "1m3agn" }], ["circle", { cx: "9", cy: "9", r: "2", key: "af1f0g" }], ["path", { d: "m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21", key: "1xmnt7" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const Italic = createLucideIcon("Italic", [ ["line", { x1: "19", x2: "10", y1: "4", y2: "4", key: "15jd3p" }], ["line", { x1: "14", x2: "5", y1: "20", y2: "20", key: "bu0au3" }], ["line", { x1: "15", x2: "9", y1: "4", y2: "20", key: "uljnxc" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const Link = createLucideIcon("Link", [ ["path", { d: "M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71", key: "1cjeqo" }], ["path", { d: "M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71", key: "19qd67" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const ListOrdered = createLucideIcon("ListOrdered", [ ["line", { x1: "10", x2: "21", y1: "6", y2: "6", key: "76qw6h" }], ["line", { x1: "10", x2: "21", y1: "12", y2: "12", key: "16nom4" }], ["line", { x1: "10", x2: "21", y1: "18", y2: "18", key: "u3jurt" }], ["path", { d: "M4 6h1v4", key: "cnovpq" }], ["path", { d: "M4 10h2", key: "16xx2s" }], ["path", { d: "M6 18H4c0-1 2-2 2-3s-1-1.5-2-1", key: "m9a95d" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const List = createLucideIcon("List", [ ["line", { x1: "8", x2: "21", y1: "6", y2: "6", key: "7ey8pc" }], ["line", { x1: "8", x2: "21", y1: "12", y2: "12", key: "rjfblc" }], ["line", { x1: "8", x2: "21", y1: "18", y2: "18", key: "c3b1m8" }], ["line", { x1: "3", x2: "3.01", y1: "6", y2: "6", key: "1g7gq3" }], ["line", { x1: "3", x2: "3.01", y1: "12", y2: "12", key: "1pjlvk" }], ["line", { x1: "3", x2: "3.01", y1: "18", y2: "18", key: "28t2mc" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const Palette = createLucideIcon("Palette", [ ["circle", { cx: "13.5", cy: "6.5", r: ".5", fill: "currentColor", key: "1okk4w" }], ["circle", { cx: "17.5", cy: "10.5", r: ".5", fill: "currentColor", key: "f64h9f" }], ["circle", { cx: "8.5", cy: "7.5", r: ".5", fill: "currentColor", key: "fotxhn" }], ["circle", { cx: "6.5", cy: "12.5", r: ".5", fill: "currentColor", key: "qy21gx" }], [ "path", { d: "M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.926 0 1.648-.746 1.648-1.688 0-.437-.18-.835-.437-1.125-.29-.289-.438-.652-.438-1.125a1.64 1.64 0 0 1 1.668-1.668h1.996c3.051 0 5.555-2.503 5.555-5.554C21.965 6.012 17.461 2 12 2z", key: "12rzf8" } ] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const Redo = createLucideIcon("Redo", [ ["path", { d: "M21 7v6h-6", key: "3ptur4" }], ["path", { d: "M3 17a9 9 0 0 1 9-9 9 9 0 0 1 6 2.3l3 2.7", key: "1kgawr" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const Type = createLucideIcon("Type", [ ["polyline", { points: "4 7 4 4 20 4 20 7", key: "1nosan" }], ["line", { x1: "9", x2: "15", y1: "20", y2: "20", key: "swin9y" }], ["line", { x1: "12", x2: "12", y1: "4", y2: "20", key: "1tx1rr" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const Underline = createLucideIcon("Underline", [ ["path", { d: "M6 4v6a6 6 0 0 0 12 0V4", key: "9kb039" }], ["line", { x1: "4", x2: "20", y1: "20", y2: "20", key: "nun2al" }] ]); /** * @license lucide-react v0.344.0 - ISC * * This source code is licensed under the ISC license. * See the LICENSE file in the root directory of this source tree. */ const Undo = createLucideIcon("Undo", [ ["path", { d: "M3 7v6h6", key: "1v2h90" }], ["path", { d: "M21 17a9 9 0 0 0-9-9 9 9 0 0 0-6 2.3L3 13", key: "1r6uu6" }] ]); function Dropdown({ options, onChange }) { return (jsxs("div", { className: "dropdown", children: [jsx("select", { onChange: (e) => onChange(e.target.value), children: options.map((option) => (jsx("option", { value: option.value, children: option.label }, option.value))) }), jsx(ChevronDown, { size: 16, className: "dropdown-icon" })] })); } const EditorContext = createContext(undefined); function EditorProvider({ children, initialContent = '', onChange }) { const [content, setContent] = useState(initialContent); const previousContentRef = useRef(initialContent); useEffect(() => { if (onChange && previousContentRef.current !== content) { onChange(content); previousContentRef.current = content; } }, [content, onChange]); const execCommand = (command, value) => { document.execCommand(command, false, value); }; const formatBlock = (blockType) => { document.execCommand('formatBlock', false, blockType); }; const wordCount = content.trim() ? content.trim().replace(/<[^>]*>/g, ' ').split(/\s+/).filter(Boolean).length : 0; const characterCount = content.trim() ? content.trim().replace(/&nbsp;/g, " ").replace(/<[^>]*>/g, ' ').length : 0; return (jsx(EditorContext.Provider, { value: { content, setContent, wordCount, characterCount, execCommand, formatBlock, }, children: children })); } function useEditorContext() { const context = useContext(EditorContext); if (context === undefined) { throw new Error('useEditorContext must be used within an EditorProvider'); } return context; } const predefinedColors = [ '#000000', '#333333', '#666666', '#999999', '#cccccc', '#ffffff', '#ff0000', '#ff6600', '#ffcc00', '#00ff00', '#0066ff', '#6600ff', '#ff3366', '#ff9933', '#ffff00', '#33ff33', '#3366ff', '#9933ff', '#cc0000', '#cc6600', '#cccc00', '#00cc00', '#0066cc', '#6600cc', '#990000', '#996600', '#999900', '#009900', '#006699', '#660099' ]; function ColorPicker({ onColorSelect, icon, tooltip }) { const [isOpen, setIsOpen] = useState(false); const [customColor, setCustomColor] = useState('#000000'); const dropdownRef = useRef(null); useEffect(() => { const handleClickOutside = (event) => { if (dropdownRef.current && !dropdownRef.current.contains(event.target)) { setIsOpen(false); } }; document.addEventListener('mousedown', handleClickOutside); return () => document.removeEventListener('mousedown', handleClickOutside); }, []); const handleColorClick = (color) => { onColorSelect(color); setIsOpen(false); }; const handleCustomColorChange = (e) => { const color = e.target.value; setCustomColor(color); onColorSelect(color); }; return (jsxs("div", { className: "color-picker", ref: dropdownRef, children: [jsxs("button", { type: "button", className: "toolbar-button color-picker-button", onClick: () => setIsOpen(!isOpen), title: tooltip, children: [icon, jsx(ChevronDown, { size: 12, className: "color-picker-arrow" })] }), isOpen && (jsxs("div", { className: "color-picker-dropdown", children: [jsx("div", { className: "color-grid", children: predefinedColors.map((color) => (jsx("button", { type: "button", className: "color-swatch", style: { backgroundColor: color }, onClick: () => handleColorClick(color), title: color }, color))) }), jsxs("div", { className: "custom-color-section", children: [jsx("label", { htmlFor: "custom-color", children: "Custom:" }), jsx("input", { id: "custom-color", type: "color", value: customColor, onChange: handleCustomColorChange, className: "custom-color-input" })] })] }))] })); } function Toolbar() { const { execCommand, formatBlock } = useEditorContext(); const [activeFormats, setActiveFormats] = useState({ bold: false, italic: false, underline: false, justifyLeft: false, justifyCenter: false, justifyRight: false, justifyFull: false, insertUnorderedList: false, insertOrderedList: false, }); function isFormatActive(tagName) { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return false; let node = selection.anchorNode; if (node && node.nodeType === 3) { node = node.parentElement; } while (node && node !== document.body) { if (node.tagName?.toLowerCase() === tagName.toLowerCase()) { return true; } node = node.parentElement; } return false; } function getCurrentAlignment() { const selection = window.getSelection(); if (!selection || selection.rangeCount === 0) return null; let node = selection.anchorNode; if (node?.nodeType === Node.TEXT_NODE) { node = node.parentElement; } while (node && node !== document.body) { const align = node.style?.textAlign || window.getComputedStyle(node).textAlign; if (['left', 'center', 'right', 'justify'].includes(align)) { return align; } node = node.parentElement; } return null; } const updateActiveFormats = () => { const alignment = getCurrentAlignment(); setActiveFormats({ bold: isFormatActive('b') || isFormatActive('strong'), italic: isFormatActive('i') || isFormatActive('em'), underline: isFormatActive('u'), justifyLeft: alignment === 'left', justifyCenter: alignment === 'center', justifyRight: alignment === 'right', justifyFull: alignment === 'justify', insertUnorderedList: isFormatActive('ul'), insertOrderedList: isFormatActive('ol'), }); }; useEffect(() => { document.addEventListener('selectionchange', updateActiveFormats); return () => { document.removeEventListener('selectionchange', updateActiveFormats); }; }, []); const handleBlockChange = (value) => { formatBlock(value); }; const handleLink = () => { const url = prompt('Enter URL:'); if (url) { execCommand('createLink', url); } }; const handleImage = () => { const url = prompt('Enter image URL:'); if (url) { execCommand('insertImage', url); } }; const handleTextColor = (color) => { execCommand('foreColor', color); }; const handleBackgroundColor = (color) => { execCommand('hiliteColor', color); }; const handleExec = (command) => { execCommand(command); setActiveFormats(prev => ({ ...prev, [command]: !prev[command] })); }; return (jsxs("div", { className: "toolbar", children: [jsxs(ToolbarGroup, { children: [jsx(ToolbarButton, { icon: jsx(Undo, { size: 18 }), tooltip: "Undo", onClick: () => handleExec('undo') }), jsx(ToolbarButton, { icon: jsx(Redo, { size: 18 }), tooltip: "Redo", onClick: () => handleExec('redo') })] }), jsx(ToolbarGroup, { children: jsx(Dropdown, { options: [ { label: 'Paragraph', value: 'p' }, { label: 'Heading 1', value: 'h1' }, { label: 'Heading 2', value: 'h2' }, { label: 'Heading 3', value: 'h3' }, { label: 'Heading 4', value: 'h4' }, { label: 'Heading 5', value: 'h5' }, { label: 'Heading 6', value: 'h6' }, ], onChange: handleBlockChange }) }), jsxs(ToolbarGroup, { children: [jsx(ToolbarButton, { icon: jsx(Bold, { size: 18 }), tooltip: "Bold", onClick: () => handleExec('bold'), active: activeFormats.bold }), jsx(ToolbarButton, { icon: jsx(Italic, { size: 18 }), tooltip: "Italic", onClick: () => handleExec('italic'), active: activeFormats.italic }), jsx(ToolbarButton, { icon: jsx(Underline, { size: 18 }), tooltip: "Underline", onClick: () => handleExec('underline'), active: activeFormats.underline })] }), jsxs(ToolbarGroup, { children: [jsx(ColorPicker, { icon: jsx(Type, { size: 18 }), tooltip: "Text Color", onColorSelect: handleTextColor }), jsx(ColorPicker, { icon: jsx(Palette, { size: 18 }), tooltip: "Background Color", onColorSelect: handleBackgroundColor })] }), jsxs(ToolbarGroup, { children: [jsx(ToolbarButton, { icon: jsx(AlignLeft, { size: 18 }), tooltip: "Align Left", onClick: () => handleExec('justifyLeft'), active: activeFormats.justifyLeft }), jsx(ToolbarButton, { icon: jsx(AlignCenter, { size: 18 }), tooltip: "Align Center", onClick: () => handleExec('justifyCenter'), active: activeFormats.justifyCenter }), jsx(ToolbarButton, { icon: jsx(AlignRight, { size: 18 }), tooltip: "Align Right", onClick: () => handleExec('justifyRight'), active: activeFormats.justifyRight }), jsx(ToolbarButton, { icon: jsx(AlignJustify, { size: 18 }), tooltip: "Justify", onClick: () => handleExec('justifyFull'), active: activeFormats.justifyFull })] }), jsxs(ToolbarGroup, { children: [jsx(ToolbarButton, { icon: jsx(List, { size: 18 }), tooltip: "Bullet List", onClick: () => handleExec('insertUnorderedList'), active: activeFormats.insertUnorderedList }), jsx(ToolbarButton, { icon: jsx(ListOrdered, { size: 18 }), tooltip: "Numbered List", onClick: () => handleExec('insertOrderedList'), active: activeFormats.insertOrderedList })] }), jsxs(ToolbarGroup, { children: [jsx(ToolbarButton, { icon: jsx(Link, { size: 18 }), tooltip: "Insert Link", onClick: handleLink }), jsx(ToolbarButton, { icon: jsx(Image, { size: 18 }), tooltip: "Insert Image", onClick: handleImage })] })] })); } function EditorContent() { const { content, setContent } = useEditorContext(); const editorRef = useRef(null); const savedRange = useRef(null); const [isInitial, setIsInitial] = useState(true); const saveCaretPosition = () => { const selection = window.getSelection(); if (selection && selection.rangeCount > 0) { savedRange.current = selection.getRangeAt(0).cloneRange(); } }; const restoreCaretPosition = () => { const selection = window.getSelection(); if (savedRange.current && selection) { try { selection.removeAllRanges(); selection.addRange(savedRange.current); } catch (e) { console.warn('Unable to restore caret:', e); } } }; const handleInput = (e) => { saveCaretPosition(); setContent(e.currentTarget.innerHTML); }; useLayoutEffect(() => { if (editorRef.current) { editorRef.current.focus(); restoreCaretPosition(); } }, [content]); useEffect(() => { if (editorRef.current && isInitial) { editorRef.current.innerHTML = content; setIsInitial(false); } }, [content, isInitial]); const handleWrapperClick = () => { if (editorRef.current) { editorRef.current.focus(); } }; return (jsx("div", { className: "editor-content", onClick: handleWrapperClick, children: jsx("div", { ref: editorRef, className: "editor-area", contentEditable: true, onInput: handleInput, onKeyUp: saveCaretPosition, onMouseUp: saveCaretPosition, suppressContentEditableWarning: true }) })); } function StatusBar() { const { wordCount, characterCount } = useEditorContext(); return (jsxs("div", { className: "status-bar", children: [jsxs("div", { children: ["Words: ", wordCount] }), jsxs("div", { children: ["Characters: ", characterCount] })] })); } function Editor() { return (jsxs("div", { className: "editor-container", children: [jsx(Toolbar, {}), jsx(EditorContent, {}), jsx(StatusBar, {})] })); } function PMEditorContent({ value }) { const { content, setContent } = useEditorContext(); const isInitial = useRef(true); useEffect(() => { if (isInitial.current && value !== undefined) { setContent(value); isInitial.current = false; } }, [value]); return jsx(Editor, {}); } function PMEditorHook({ onChange, value }) { return (jsx(EditorProvider, { initialContent: value, onChange: onChange, children: jsx(PMEditorContent, { onChange: onChange, value: value }) })); } export { PMEditorHook as default }; //# sourceMappingURL=index.esm.js.map