react-zen-editor
Version:
A modern, feature-rich WYSIWYG editor for React with Korean/English support
809 lines (790 loc) • 77.3 kB
JavaScript
import { jsx, jsxs } from 'react/jsx-runtime';
import { useCallback, useState, useRef, useEffect } from 'react';
import { ChevronDown, Bold, Italic, Underline, AlignLeft, AlignCenter, AlignRight, List, ListOrdered, Link, Image, Video, Minus, Hash, Type, ChevronsUpDown, Heading, AlignJustify, Undo, Redo, Copy } from 'lucide-react';
/******************************************************************************
Copyright (c) Microsoft Corporation.
Permission to use, copy, modify, and/or distribute this software for any
purpose with or without fee is hereby granted.
THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
PERFORMANCE OF THIS SOFTWARE.
***************************************************************************** */
/* global Reflect, Promise, SuppressedError, Symbol, Iterator */
var __assign = function() {
__assign = Object.assign || function __assign(t) {
for (var s, i = 1, n = arguments.length; i < n; i++) {
s = arguments[i];
for (var p in s) if (Object.prototype.hasOwnProperty.call(s, p)) t[p] = s[p];
}
return t;
};
return __assign.apply(this, arguments);
};
function __awaiter(thisArg, _arguments, P, generator) {
function adopt(value) { return value instanceof P ? value : new P(function (resolve) { resolve(value); }); }
return new (P || (P = Promise))(function (resolve, reject) {
function fulfilled(value) { try { step(generator.next(value)); } catch (e) { reject(e); } }
function rejected(value) { try { step(generator["throw"](value)); } catch (e) { reject(e); } }
function step(result) { result.done ? resolve(result.value) : adopt(result.value).then(fulfilled, rejected); }
step((generator = generator.apply(thisArg, _arguments || [])).next());
});
}
function __generator(thisArg, body) {
var _ = { label: 0, sent: function() { if (t[0] & 1) throw t[1]; return t[1]; }, trys: [], ops: [] }, f, y, t, g = Object.create((typeof Iterator === "function" ? Iterator : Object).prototype);
return g.next = verb(0), g["throw"] = verb(1), g["return"] = verb(2), typeof Symbol === "function" && (g[Symbol.iterator] = function() { return this; }), g;
function verb(n) { return function (v) { return step([n, v]); }; }
function step(op) {
if (f) throw new TypeError("Generator is already executing.");
while (g && (g = 0, op[0] && (_ = 0)), _) try {
if (f = 1, y && (t = op[0] & 2 ? y["return"] : op[0] ? y["throw"] || ((t = y["return"]) && t.call(y), 0) : y.next) && !(t = t.call(y, op[1])).done) return t;
if (y = 0, t) op = [op[0] & 2, t.value];
switch (op[0]) {
case 0: case 1: t = op; break;
case 4: _.label++; return { value: op[1], done: false };
case 5: _.label++; y = op[1]; op = [0]; continue;
case 7: op = _.ops.pop(); _.trys.pop(); continue;
default:
if (!(t = _.trys, t = t.length > 0 && t[t.length - 1]) && (op[0] === 6 || op[0] === 2)) { _ = 0; continue; }
if (op[0] === 3 && (!t || (op[1] > t[0] && op[1] < t[3]))) { _.label = op[1]; break; }
if (op[0] === 6 && _.label < t[1]) { _.label = t[1]; t = op; break; }
if (t && _.label < t[2]) { _.label = t[2]; _.ops.push(op); break; }
if (t[2]) _.ops.pop();
_.trys.pop(); continue;
}
op = body.call(thisArg, _);
} catch (e) { op = [6, e]; y = 0; } finally { f = t = 0; }
if (op[0] & 5) throw op[1]; return { value: op[0] ? op[1] : void 0, done: true };
}
}
typeof SuppressedError === "function" ? SuppressedError : function (error, suppressed, message) {
var e = new Error(message);
return e.name = "SuppressedError", e.error = error, e.suppressed = suppressed, e;
};
// 에디터 유틸리티 함수들
var isYouTubeUrl$1 = function (url) {
var youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
return youtubeRegex.test(url);
};
var getYouTubeVideoId = function (url) {
var youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
var match = url.match(youtubeRegex);
return match ? match[1] : null;
};
var getCurrentAlignment = function (editorRef) {
var selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
var element = selection.anchorNode;
if (element && element.nodeType === Node.TEXT_NODE) {
element = element.parentElement;
}
// 현재 요소나 부모 요소에서 text-align 스타일 찾기
while (element && element !== editorRef.current) {
if (element instanceof Element) {
var computedStyle = window.getComputedStyle(element);
var textAlign = computedStyle.textAlign;
if (textAlign && textAlign !== 'start') {
return textAlign;
}
// 인라인 스타일도 확인
if (element instanceof HTMLElement) {
var inlineStyle = element.style.textAlign;
if (inlineStyle) {
return inlineStyle;
}
}
}
element = element.parentElement;
}
}
return 'left'; // 기본값
};
// 에디터 스타일 정의
var editorStyles = "\n .editor-content h1,\n .editor-content h1 *,\n .editor-content h1[style] {\n font-size: 2rem !important;\n font-weight: bold !important;\n margin: 1rem 0 0.5rem 0 !important;\n line-height: 1.2 !important;\n color: #1a202c !important;\n display: block !important;\n }\n .editor-content h2,\n .editor-content h2 *,\n .editor-content h2[style] {\n font-size: 1.5rem !important;\n font-weight: bold !important;\n margin: 0.875rem 0 0.5rem 0 !important;\n line-height: 1.3 !important;\n color: #2d3748 !important;\n display: block !important;\n }\n .editor-content h3,\n .editor-content h3 *,\n .editor-content h3[style] {\n font-size: 1.25rem !important;\n font-weight: bold !important;\n margin: 0.75rem 0 0.5rem 0 !important;\n line-height: 1.4 !important;\n color: #4a5568 !important;\n display: block !important;\n }\n .editor-content p {\n margin: 0.5rem 0;\n line-height: 1.6;\n }\n .editor-content ul,\n .editor-content ul[style] {\n margin: 0.5rem 0 !important;\n padding-left: 1.5rem !important;\n list-style-type: disc !important;\n display: block !important;\n }\n .editor-content ol,\n .editor-content ol[style] {\n margin: 0.5rem 0 !important;\n padding-left: 1.5rem !important;\n list-style-type: decimal !important;\n display: block !important;\n }\n .editor-content li,\n .editor-content li[style] {\n margin: 0.25rem 0 !important;\n line-height: 1.5 !important;\n display: list-item !important;\n }\n .editor-content ul ul {\n list-style-type: circle;\n margin: 0.25rem 0;\n }\n .editor-content ol ol {\n list-style-type: lower-alpha;\n margin: 0.25rem 0;\n }\n .editor-content strong {\n font-weight: bold;\n }\n .editor-content em {\n font-style: italic;\n }\n .editor-content u {\n text-decoration: underline;\n }\n .editor-content blockquote {\n border-left: 4px solid #e2e8f0;\n padding-left: 1rem;\n margin: 1rem 0;\n color: #4a5568;\n font-style: italic;\n }\n .editor-content code {\n background-color: #f7fafc;\n padding: 0.125rem 0.25rem;\n border-radius: 0.25rem;\n font-family: 'Courier New', monospace;\n font-size: 0.875rem;\n }\n .editor-content pre {\n background-color: #f7fafc;\n padding: 1rem;\n border-radius: 0.5rem;\n overflow-x: auto;\n margin: 1rem 0;\n }\n .editor-content a {\n color: #1d4ed8;\n text-decoration: underline;\n cursor: pointer;\n }\n .editor-content a:hover {\n color: #1e40af;\n text-decoration: underline;\n }\n .editor-content a:visited {\n color: #7c3aed;\n }\n .editor-content iframe {\n border: 1px solid #e2e8f0;\n border-radius: 0.5rem;\n }\n .editor-content video {\n border: 1px solid #e2e8f0;\n border-radius: 0.5rem;\n }\n .editor-content img {\n border-radius: 0.5rem;\n box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);\n }\n /* \uAE00\uC790 \uD06C\uAE30 \uC2A4\uD0C0\uC77C */\n .editor-content font[size=\"1\"] {\n font-size: 10px;\n }\n .editor-content font[size=\"2\"] {\n font-size: 13px;\n }\n .editor-content font[size=\"3\"] {\n font-size: 16px;\n }\n .editor-content font[size=\"4\"] {\n font-size: 18px;\n }\n .editor-content font[size=\"5\"] {\n font-size: 24px;\n }\n .editor-content font[size=\"6\"] {\n font-size: 32px;\n }\n .editor-content font[size=\"7\"] {\n font-size: 48px;\n }\n /* \uAE30\uBCF8 \uC815\uB82C \uC2A4\uD0C0\uC77C - text-align\uC774 \uC5C6\uB294 \uACBD\uC6B0 \uC67C\uCABD \uC815\uB82C */\n .editor-content p,\n .editor-content div {\n text-align: left;\n }\n .editor-content h1,\n .editor-content h2,\n .editor-content h3 {\n text-align: left;\n margin-bottom: 0.5rem;\n }\n /* \uBA85\uC2DC\uC801 \uC815\uB82C \uC2A4\uD0C0\uC77C */\n .editor-content [style*=\"text-align: center\"] {\n text-align: center !important;\n }\n .editor-content [style*=\"text-align: right\"] {\n text-align: right !important;\n }\n .editor-content [style*=\"text-align: left\"] {\n text-align: left !important;\n }\n /* \uD3EC\uCEE4\uC2A4 \uC2DC \uC544\uC6C3\uB77C\uC778 \uC81C\uAC70 */\n .editor-content:focus {\n outline: none;\n }\n /* \uBE48 \uD0DC\uADF8\uC5D0 \uB300\uD55C \uCD5C\uC18C \uB192\uC774 */\n .editor-content p:empty::before {\n content: \"\\00a0\";\n color: transparent;\n }\n .editor-content div:empty::before {\n content: \"\\00a0\";\n color: transparent;\n }\n";
var ToolbarButton = function (_a) {
var command = _a.command, Icon = _a.icon, title = _a.title, _b = _a.value, value = _b === void 0 ? null : _b, _c = _a.onClick, onClick = _c === void 0 ? null : _c, executeCommand = _a.executeCommand;
var handleClick = useCallback(function () {
if (onClick) {
onClick();
}
else if (command) {
executeCommand(command, value);
}
}, [onClick, command, value, executeCommand]);
return (jsx("button", { type: "button", className: "p-2 hover:bg-gray-200 rounded transition-colors", title: title, onMouseDown: function (e) { return e.preventDefault(); }, onClick: handleClick, children: jsx(Icon, { size: 16 }) }));
};
var DropdownButton = function (_a) {
var Icon = _a.icon, title = _a.title, options = _a.options, onOptionSelect = _a.onOptionSelect; _a.placeholder; var CustomDropdownIcon = _a.dropdownIcon;
var _c = useState(false), isOpen = _c[0], setIsOpen = _c[1];
var dropdownRef = useRef(null);
// 외부 클릭 감지
useEffect(function () {
var handleClickOutside = function (event) {
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
setIsOpen(false);
}
};
if (isOpen) {
document.addEventListener('mousedown', handleClickOutside);
}
return function () {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [isOpen]);
var handleOptionClick = function (value) {
onOptionSelect(value);
setIsOpen(false);
};
return (jsxs("div", { className: "relative", ref: dropdownRef, children: [jsxs("button", { type: "button", className: "p-2 hover:bg-gray-200 rounded transition-colors flex items-center gap-1", onClick: function () { return setIsOpen(!isOpen); }, title: title, children: [jsx(Icon, { size: 16 }), CustomDropdownIcon ? (jsx(CustomDropdownIcon, { size: 12 })) : (jsx(ChevronDown, { size: 12, className: "transition-transform ".concat(isOpen ? 'rotate-180' : '') }))] }), isOpen && (jsx("div", { className: "absolute top-full left-0 mt-1 bg-white border border-gray-300 rounded-lg shadow-lg z-50 min-w-[160px]", children: jsx("div", { className: "py-1", children: options.map(function (option, index) { return (jsx("button", { type: "button", className: "w-full text-left px-3 py-2 text-sm hover:bg-gray-100 transition-colors", onClick: function () { return handleOptionClick(option.value); }, children: option.label }, index)); }) }) }))] }));
};
var ColorPicker = function (_a) {
var type = _a.type, onColorSelect = _a.onColorSelect;
var textColors = {
basic: ['#000000', '#333333', '#666666', '#999999', '#CCCCCC', '#FFFFFF', '#FF0000', '#0000FF'],
theme: ['#FF6B6B', '#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7', '#DDA0DD', '#98D8C8', '#F7DC6F'],
standard: [
'#FF0000', '#FF8000', '#FFFF00', '#80FF00',
'#00FF00', '#00FF80', '#00FFFF', '#0080FF',
'#0000FF', '#8000FF', '#FF00FF', '#FF0080',
'#800000', '#804000', '#808000', '#408000'
]
};
var backgroundColors = {
basic: ['transparent', '#FFFFFF', '#F8F9FA', '#E9ECEF', '#DEE2E6', '#CED4DA', '#ADB5BD', '#6C757D'],
highlight: ['#FFF3CD', '#D1ECF1', '#D4EDDA', '#F8D7DA', '#E2E3E5', '#D6F5D6', '#FFE5CC', '#E7D3FF'],
fluorescent: [
'#FFFF99', '#99FF99', '#99FFFF', '#FF99FF',
'#FFB399', '#B3B3FF', '#FFD700', '#FF6B6B',
'#4ECDC4', '#45B7D1', '#96CEB4', '#FFEAA7',
'#DDA0DD', '#98D8C8', '#F7DC6F', '#FF9FF3'
]
};
var colors = type === 'text' ? textColors : backgroundColors;
var labels = type === 'text'
? { basic: '기본 색상', theme: '테마 색상', standard: '표준 색상' }
: { basic: '기본 배경', highlight: '하이라이트', fluorescent: '형광펜' };
return (jsxs("div", { className: "absolute top-full left-0 mt-1 p-2 bg-white border border-gray-300 rounded shadow-lg z-50 w-52", children: [Object.entries(colors).map(function (_a) {
var key = _a[0], colorArray = _a[1];
return (jsxs("div", { className: "mb-2", children: [jsx("div", { className: "text-xs text-gray-600 mb-1", children: labels[key] }), jsx("div", { className: "grid grid-cols-8 gap-1", children: colorArray.map(function (color) { return (jsx("button", { className: "w-6 h-6 rounded border border-gray-300 hover:scale-105 hover:border-blue-500 transition-all duration-150 relative", style: { backgroundColor: color === 'transparent' ? '#ffffff' : color }, onClick: function () { return onColorSelect(color); }, title: color === 'transparent' ? '투명' : color, children: color === 'transparent' && (jsx("div", { className: "absolute inset-0 flex items-center justify-center", children: jsx("div", { className: "w-4 h-0.5 bg-red-500 rotate-45" }) })) }, color)); }) })] }, key));
}), jsxs("div", { className: "border-t pt-2", children: [jsx("div", { className: "text-xs text-gray-600 mb-1", children: "\uC0AC\uC6A9\uC790 \uC815\uC758" }), jsx("input", { type: "color", onChange: function (e) { return onColorSelect(e.target.value); }, className: "w-full h-8 border border-gray-300 rounded cursor-pointer", title: "\uC0AC\uC6A9\uC790 \uC815\uC758 ".concat(type === 'text' ? '색상' : '배경색') })] })] }));
};
var LinkModal = function (_a) {
var modalData = _a.modalData, setModalData = _a.setModalData, selectedText = _a.selectedText, onInsert = _a.onInsert, onClose = _a.onClose;
return (jsxs("div", { className: "p-6", children: [jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "\uB9C1\uD06C \uC0BD\uC785" }), jsxs("div", { className: "space-y-4", children: [jsxs("div", { children: [jsx("label", { className: "block text-sm font-medium text-gray-700 mb-1", children: "URL *" }), jsx("input", { type: "url", value: modalData.url, onChange: function (e) { return setModalData(function (prev) { return (__assign(__assign({}, prev), { url: e.target.value })); }); }, placeholder: "https://example.com", className: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500", autoFocus: true })] }), !selectedText && (jsxs("div", { children: [jsx("label", { className: "block text-sm font-medium text-gray-700 mb-1", children: "\uB9C1\uD06C \uD14D\uC2A4\uD2B8" }), jsx("input", { type: "text", value: modalData.text, onChange: function (e) { return setModalData(function (prev) { return (__assign(__assign({}, prev), { text: e.target.value })); }); }, placeholder: "\uB9C1\uD06C \uD14D\uC2A4\uD2B8 (\uBE44\uC5B4\uC788\uC73C\uBA74 URL \uC0AC\uC6A9)", className: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" })] })), selectedText && (jsxs("div", { className: "text-sm text-gray-600", children: ["\uC120\uD0DD\uB41C \uD14D\uC2A4\uD2B8: \"", jsx("span", { className: "font-medium", children: selectedText }), "\""] })), jsxs("div", { children: [jsx("label", { className: "block text-sm font-medium text-gray-700 mb-2", children: "\uB9C1\uD06C \uC5F4\uAE30 \uBC29\uC2DD" }), jsxs("div", { className: "space-y-2", children: [jsxs("label", { className: "flex items-center", children: [jsx("input", { type: "radio", name: "target", value: "_blank", checked: modalData.target === '_blank', onChange: function (e) { return setModalData(function (prev) { return (__assign(__assign({}, prev), { target: e.target.value })); }); }, className: "mr-2 text-blue-600 focus:ring-blue-500" }), jsx("span", { className: "text-sm text-gray-700", children: "\uC0C8\uCC3D\uC5D0\uC11C \uC5F4\uAE30 (\uAD8C\uC7A5)" })] }), jsxs("label", { className: "flex items-center", children: [jsx("input", { type: "radio", name: "target", value: "_self", checked: modalData.target === '_self', onChange: function (e) { return setModalData(function (prev) { return (__assign(__assign({}, prev), { target: e.target.value })); }); }, className: "mr-2 text-blue-600 focus:ring-blue-500" }), jsx("span", { className: "text-sm text-gray-700", children: "\uD604\uC7AC\uCC3D\uC5D0\uC11C \uC5F4\uAE30" })] })] })] })] }), jsxs("div", { className: "flex justify-end gap-3 mt-6", children: [jsx("button", { onClick: onClose, className: "px-4 py-2 text-gray-600 bg-gray-100 rounded hover:bg-gray-200 transition-colors", children: "\uCDE8\uC18C" }), jsx("button", { onClick: onInsert, disabled: !modalData.url, className: "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors", children: "\uC0BD\uC785" })] })] }));
};
var ImageModal = function (_a) {
var modalData = _a.modalData, setModalData = _a.setModalData, onInsert = _a.onInsert, onClose = _a.onClose;
return (jsxs("div", { className: "p-6", children: [jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "\uC774\uBBF8\uC9C0 \uC0BD\uC785" }), jsxs("div", { className: "space-y-4", children: [jsxs("div", { children: [jsx("label", { className: "block text-sm font-medium text-gray-700 mb-1", children: "\uC774\uBBF8\uC9C0 URL *" }), jsx("input", { type: "url", value: modalData.url, onChange: function (e) { return setModalData(function (prev) { return (__assign(__assign({}, prev), { url: e.target.value })); }); }, placeholder: "https://example.com/image.jpg", className: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500", autoFocus: true })] }), jsxs("div", { children: [jsx("label", { className: "block text-sm font-medium text-gray-700 mb-1", children: "\uB300\uCCB4 \uD14D\uC2A4\uD2B8 (Alt)" }), jsx("input", { type: "text", value: modalData.alt, onChange: function (e) { return setModalData(function (prev) { return (__assign(__assign({}, prev), { alt: e.target.value })); }); }, placeholder: "\uC774\uBBF8\uC9C0 \uC124\uBA85", className: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500" })] }), modalData.url && (jsx("div", { className: "text-xs text-gray-500", children: "\uD83D\uDCA1 \uC774\uBBF8\uC9C0\uB294 \uC790\uB3D9\uC73C\uB85C \uBC18\uC751\uD615 \uD06C\uAE30\uB85C \uC870\uC815\uB429\uB2C8\uB2E4" }))] }), jsxs("div", { className: "flex justify-end gap-3 mt-6", children: [jsx("button", { onClick: onClose, className: "px-4 py-2 text-gray-600 bg-gray-100 rounded hover:bg-gray-200 transition-colors", children: "\uCDE8\uC18C" }), jsx("button", { onClick: onInsert, disabled: !modalData.url, className: "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors", children: "\uC0BD\uC785" })] })] }));
};
// 에디터 유틸리티 함수들
var isYouTubeUrl = function (url) {
var youtubeRegex = /(?:https?:\/\/)?(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/|v\/|shorts\/)|youtu\.be\/)([a-zA-Z0-9_-]{11})/;
return youtubeRegex.test(url);
};
var VideoModal = function (_a) {
var modalData = _a.modalData, setModalData = _a.setModalData, onInsert = _a.onInsert, onClose = _a.onClose;
return (jsxs("div", { className: "p-6", children: [jsx("h3", { className: "text-lg font-semibold mb-4 text-gray-800", children: "\uB3D9\uC601\uC0C1 \uC0BD\uC785" }), jsxs("div", { className: "space-y-4", children: [jsxs("div", { children: [jsx("label", { className: "block text-sm font-medium text-gray-700 mb-1", children: "\uB3D9\uC601\uC0C1 URL *" }), jsx("input", { type: "url", value: modalData.url, onChange: function (e) { return setModalData(function (prev) { return (__assign(__assign({}, prev), { url: e.target.value })); }); }, placeholder: "https://www.youtube.com/watch?v=... \uB610\uB294 \uB3D9\uC601\uC0C1 \uD30C\uC77C URL", className: "w-full px-3 py-2 border border-gray-300 rounded-md focus:outline-none focus:ring-2 focus:ring-blue-500", autoFocus: true })] }), jsxs("div", { className: "bg-blue-50 p-3 rounded-md text-sm text-blue-800", children: [jsx("div", { className: "font-medium mb-1", children: "\uC9C0\uC6D0\uB418\uB294 \uD615\uC2DD:" }), jsxs("ul", { className: "text-xs space-y-1", children: [jsx("li", { children: "\u2022 YouTube URL (\uC790\uB3D9\uC73C\uB85C \uC784\uBCA0\uB4DC \uD615\uD0DC\uB85C \uBCC0\uD658)" }), jsx("li", { children: "\u2022 \uC9C1\uC811 \uB3D9\uC601\uC0C1 \uD30C\uC77C URL (.mp4, .webm \uB4F1)" })] })] }), modalData.url && isYouTubeUrl(modalData.url) && (jsx("div", { className: "text-xs text-green-600", children: "\u2705 YouTube URL\uC774 \uAC10\uC9C0\uB418\uC5C8\uC2B5\uB2C8\uB2E4. \uC784\uBCA0\uB4DC \uD615\uD0DC\uB85C \uC0BD\uC785\uB429\uB2C8\uB2E4." }))] }), jsxs("div", { className: "flex justify-end gap-3 mt-6", children: [jsx("button", { onClick: onClose, className: "px-4 py-2 text-gray-600 bg-gray-100 rounded hover:bg-gray-200 transition-colors", children: "\uCDE8\uC18C" }), jsx("button", { onClick: onInsert, disabled: !modalData.url, className: "px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed transition-colors", children: "\uC0BD\uC785" })] })] }));
};
var TextColorIcon = function (_a) {
var _b = _a.color, color = _b === void 0 ? '#000000' : _b; _a.currentColor; var _d = _a.size, size = _d === void 0 ? 16 : _d;
return (jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", xmlns: "http://www.w3.org/2000/svg", children: jsx("path", { d: "M4 7V5H20V7H13V19H11V7H4Z", fill: color, stroke: color, strokeWidth: "0.5" }) }));
};
var BackgroundColorIcon = function (_a) {
_a.backgroundColor; _a.currentColor; var _d = _a.size, size = _d === void 0 ? 16 : _d;
return (jsx("svg", { width: size, height: size, viewBox: "0 0 24 24", fill: "none", xmlns: "http://www.w3.org/2000/svg", children: jsx("path", { d: "M4 7V5H20V7H13V19H11V7H4Z", fill: "#000000", stroke: "#000000", strokeWidth: "0.5" }) }));
};
var CustomEditor = function (_a) {
var _b, _c;
var _d = _a.value, value = _d === void 0 ? '' : _d, _e = _a.onChange, onChange = _e === void 0 ? function () { } : _e;
// 다국어 처리 (Hydration mismatch 방지를 위해 클라이언트에서만 감지)
var _f = useState(true), isKorean = _f[0], setIsKorean = _f[1]; // 기본값: 한국어
var texts = {
ko: {
// 툴바
bold: '굵게',
italic: '기울임',
underline: '밑줄',
alignLeft: '왼쪽 정렬 (토글)',
alignCenter: '가운데 정렬 (토글)',
alignRight: '오른쪽 정렬 (토글)',
bulletList: '순서 없는 목록',
numberedList: '순서 있는 목록',
insertLink: '링크 삽입',
insertImage: '이미지 삽입',
insertVideo: '동영상 삽입 (YouTube 지원)',
horizontalRule: '구분선 삽입',
specialChars: '특수문자 삽입',
textColor: '글자 색상',
backgroundColor: '배경 색상',
undo: '되돌리기 (Ctrl+Z)',
redo: '다시실행 (Ctrl+Y)',
// 드롭다운
style: '스타일',
fontSize: '크기',
lineHeight: '줄간격',
normalParagraph: '일반 문단',
heading1: '제목 1 (큰 제목)',
heading2: '제목 2 (중간 제목)',
heading3: '제목 3 (작은 제목)',
verySmall: '매우 작게 (10px)',
small: '작게 (13px)',
normal: '보통 (16px)',
large: '크게 (18px)',
veryLarge: '매우 크게 (24px)',
extraLarge: '특대 (32px)',
huge: '초대형 (48px)',
lineHeight1: '줄간격 1.0',
lineHeight12: '줄간격 1.2',
lineHeight14: '줄간격 1.4',
lineHeight16: '줄간격 1.6',
lineHeight18: '줄간격 1.8',
lineHeight2: '줄간격 2.0',
lineHeight25: '줄간격 2.5',
// 하단
characterCount: '글자 수',
htmlView: 'HTML 보기',
editorView: '에디터 보기',
copyToClipboard: 'HTML 복사',
copySuccess: '복사완료',
copyError: '복사 실패',
// 특수문자
insertChar: '삽입',
},
en: {
// 툴바
bold: 'Bold',
italic: 'Italic',
underline: 'Underline',
alignLeft: 'Align Left (Toggle)',
alignCenter: 'Align Center (Toggle)',
alignRight: 'Align Right (Toggle)',
bulletList: 'Bullet List',
numberedList: 'Numbered List',
insertLink: 'Insert Link',
insertImage: 'Insert Image',
insertVideo: 'Insert Video (YouTube Support)',
horizontalRule: 'Insert Horizontal Rule',
specialChars: 'Insert Special Characters',
textColor: 'Text Color',
backgroundColor: 'Background Color',
undo: 'Undo (Ctrl+Z)',
redo: 'Redo (Ctrl+Y)',
// 드롭다운
style: 'Style',
fontSize: 'Size',
lineHeight: 'Line Height',
normalParagraph: 'Normal Paragraph',
heading1: 'Heading 1 (Large)',
heading2: 'Heading 2 (Medium)',
heading3: 'Heading 3 (Small)',
verySmall: 'Very Small (10px)',
small: 'Small (13px)',
normal: 'Normal (16px)',
large: 'Large (18px)',
veryLarge: 'Very Large (24px)',
extraLarge: 'Extra Large (32px)',
huge: 'Huge (48px)',
lineHeight1: 'Line Height 1.0',
lineHeight12: 'Line Height 1.2',
lineHeight14: 'Line Height 1.4',
lineHeight16: 'Line Height 1.6',
lineHeight18: 'Line Height 1.8',
lineHeight2: 'Line Height 2.0',
lineHeight25: 'Line Height 2.5',
// 하단
characterCount: 'Character Count',
htmlView: 'HTML View',
editorView: 'Editor View',
copyToClipboard: 'Copy HTML',
copySuccess: 'Copied!',
copyError: 'Failed',
// 특수문자
insertChar: 'Insert',
}
};
var t = texts[isKorean ? 'ko' : 'en'];
var _g = useState(false), htmlMode = _g[0], setHtmlMode = _g[1];
var _h = useState(value), htmlContent = _h[0], setHtmlContent = _h[1];
var _j = useState(null), activeModal = _j[0], setActiveModal = _j[1];
var _k = useState({ url: '', text: '', alt: '', target: '_blank' }), modalData = _k[0], setModalData = _k[1];
var _l = useState(''), selectedText = _l[0], setSelectedText = _l[1];
var _m = useState(null), savedRange = _m[0], setSavedRange = _m[1];
var _o = useState(false), showTextColorPicker = _o[0], setShowTextColorPicker = _o[1];
var _p = useState(false), showBackgroundColorPicker = _p[0], setShowBackgroundColorPicker = _p[1];
var _q = useState(false), showSpecialCharPicker = _q[0], setShowSpecialCharPicker = _q[1];
var _r = useState(false), showCopySuccess = _r[0], setShowCopySuccess = _r[1];
var _s = useState('#ff0000'), currentTextColor = _s[0], setCurrentTextColor = _s[1];
var _t = useState('#ffff00'), currentBackgroundColor = _t[0], setCurrentBackgroundColor = _t[1];
var editorRef = useRef(null);
var textareaRef = useRef(null);
var isUpdatingFromParent = useRef(false);
// 브라우저의 기본 문단 구분자를 P 태그로 설정
var setParagraphSeparator = useCallback(function () {
try {
// 문단 구분자를 P 태그로 설정
document.execCommand('defaultParagraphSeparator', false, 'p');
// Chrome에서 더 확실하게 하기 위한 추가 설정
if (editorRef.current) {
var style = editorRef.current.style;
// 브라우저가 div 대신 p를 사용하도록 유도
style.setProperty('white-space', 'pre-wrap');
}
}
catch (error) {
// 일부 브라우저에서 지원하지 않을 경우 무시
console.warn('defaultParagraphSeparator not supported:', error);
}
}, []);
// 에디터 명령 실행
var executeCommand = useCallback(function (command, value) {
if (value === void 0) { value = null; }
if (editorRef.current) {
editorRef.current.focus();
// 에디터 포커스 시마다 문단 구분자 설정
setParagraphSeparator();
document.execCommand(command, false, value || undefined);
// 명령 실행 후 스타일 강제 적용
setTimeout(function () {
forceApplyStyles();
}, 50);
// 명령 실행 후 내용 업데이트
var content = editorRef.current.innerHTML;
setHtmlContent(content);
onChange(content);
}
}, [onChange]);
// 스타일 강제 적용 함수
var forceApplyStyles = useCallback(function () {
if (!editorRef.current)
return;
// 제목 태그들에 직접 스타일 적용
var headings = editorRef.current.querySelectorAll('h1, h2, h3');
headings.forEach(function (heading) {
var tagName = heading.tagName.toLowerCase();
var element = heading;
if (tagName === 'h1') {
element.style.fontSize = '2rem';
element.style.fontWeight = 'bold';
element.style.margin = '1rem 0 0.5rem 0';
element.style.color = '#1a202c';
element.style.display = 'block';
}
else if (tagName === 'h2') {
element.style.fontSize = '1.5rem';
element.style.fontWeight = 'bold';
element.style.margin = '0.875rem 0 0.5rem 0';
element.style.color = '#2d3748';
element.style.display = 'block';
}
else if (tagName === 'h3') {
element.style.fontSize = '1.25rem';
element.style.fontWeight = 'bold';
element.style.margin = '0.75rem 0 0.5rem 0';
element.style.color = '#4a5568';
element.style.display = 'block';
}
});
// 일반 문단(p) 태그의 스타일 초기화
var paragraphs = editorRef.current.querySelectorAll('p');
paragraphs.forEach(function (p) {
var element = p;
// 제목에서 문단으로 변경된 경우 불필요한 스타일 제거
element.style.removeProperty('font-size');
element.style.removeProperty('font-weight');
element.style.removeProperty('color');
element.style.removeProperty('display');
element.style.removeProperty('line-height');
element.style.removeProperty('margin');
// 스타일 속성이 완전히 비어있으면 style 속성 자체도 제거
if (!element.style.cssText) {
element.removeAttribute('style');
}
});
// div 태그 스타일 초기화 (가끔 div로 생성되는 경우)
var divs = editorRef.current.querySelectorAll('div');
divs.forEach(function (div) {
var element = div;
// 제목 스타일이 남아있는 div 태그 정리
if (element.style.fontSize && (element.style.fontSize === '2rem' || element.style.fontSize === '1.5rem' || element.style.fontSize === '1.25rem')) {
element.style.removeProperty('font-size');
element.style.removeProperty('font-weight');
element.style.removeProperty('color');
element.style.removeProperty('display');
element.style.removeProperty('margin');
}
});
// 리스트에 직접 스타일 적용
var lists = editorRef.current.querySelectorAll('ul, ol');
lists.forEach(function (list) {
var element = list;
element.style.paddingLeft = '1.5rem';
element.style.margin = '0.5rem 0';
element.style.display = 'block';
if (list.tagName.toLowerCase() === 'ul') {
element.style.listStyleType = 'disc';
}
else {
element.style.listStyleType = 'decimal';
}
});
// 리스트 아이템에 스타일 적용
var listItems = editorRef.current.querySelectorAll('li');
listItems.forEach(function (item) {
var element = item;
element.style.margin = '0.25rem 0';
element.style.lineHeight = '1.5';
element.style.display = 'list-item';
});
}, []);
// 선택된 텍스트 가져오기
useCallback(function () {
var selection = window.getSelection();
return selection ? selection.toString() : '';
}, []);
// 링크 모달 열기
var openLinkModal = useCallback(function () {
var selection = window.getSelection();
var selected = selection ? selection.toString() : '';
// 현재 선택 영역 또는 커서 위치를 저장
if (selection && selection.rangeCount > 0) {
var range = selection.getRangeAt(0);
setSavedRange(range.cloneRange());
}
else {
// 선택된 텍스트가 없어도 커서 위치 저장을 위해 에디터에 포커스
if (editorRef.current) {
editorRef.current.focus();
var newSelection = window.getSelection();
if (newSelection && newSelection.rangeCount > 0) {
var range = newSelection.getRangeAt(0);
setSavedRange(range.cloneRange());
}
}
}
setSelectedText(selected);
setModalData({ url: '', text: selected, alt: '', target: '_blank' });
setActiveModal('link');
}, []);
// 이미지 모달 열기
var openImageModal = useCallback(function () {
// 현재 커서 위치를 저장
var selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
var range = selection.getRangeAt(0);
setSavedRange(range.cloneRange());
}
else {
// 선택된 텍스트가 없어도 커서 위치 저장을 위해 에디터에 포커스
if (editorRef.current) {
editorRef.current.focus();
var newSelection = window.getSelection();
if (newSelection && newSelection.rangeCount > 0) {
var range = newSelection.getRangeAt(0);
setSavedRange(range.cloneRange());
}
}
}
setModalData({ url: '', text: '', alt: '삽입된 이미지' });
setActiveModal('image');
}, []);
// 동영상 모달 열기
var openVideoModal = useCallback(function () {
// 현재 커서 위치를 저장
var selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
var range = selection.getRangeAt(0);
setSavedRange(range.cloneRange());
}
else {
// 선택된 텍스트가 없어도 커서 위치 저장을 위해 에디터에 포커스
if (editorRef.current) {
editorRef.current.focus();
var newSelection = window.getSelection();
if (newSelection && newSelection.rangeCount > 0) {
var range = newSelection.getRangeAt(0);
setSavedRange(range.cloneRange());
}
}
}
setModalData({ url: '', text: '', alt: '' });
setActiveModal('video');
}, []);
// 모달 닫기
var closeModal = useCallback(function () {
setActiveModal(null);
setModalData({ url: '', text: '', alt: '', target: '_blank' });
setSelectedText('');
setSavedRange(null);
}, []);
// 링크 삽입 실행
var insertLink = useCallback(function () {
var url = modalData.url, text = modalData.text, target = modalData.target;
if (!url)
return;
// 에디터에 포커스 복원
if (editorRef.current) {
editorRef.current.focus();
}
if (selectedText && savedRange) {
// 저장된 선택 영역 복원 (선택된 텍스트가 있는 경우)
var selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(savedRange);
// 선택된 텍스트를 링크로 변환
var targetAttr = target === '_blank' ? ' target="_blank"' : '';
var linkHTML = "<a href=\"".concat(url, "\"").concat(targetAttr, " style=\"color: #1d4ed8; text-decoration: underline;\">").concat(selectedText, "</a>");
executeCommand('insertHTML', linkHTML);
}
}
else if (savedRange) {
// 선택된 텍스트는 없지만 저장된 커서 위치가 있는 경우
var selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(savedRange);
// 현재 커서 위치에 새 링크 삽입
var linkText = text || url;
var targetAttr = target === '_blank' ? ' target="_blank"' : '';
executeCommand('insertHTML', "<a href=\"".concat(url, "\"").concat(targetAttr, " style=\"color: #1d4ed8; text-decoration: underline;\">").concat(linkText, "</a>"));
}
}
else {
// 저장된 위치가 없으면 현재 위치에 삽입
var linkText = text || url;
var targetAttr = target === '_blank' ? ' target="_blank"' : '';
executeCommand('insertHTML', "<a href=\"".concat(url, "\"").concat(targetAttr, " style=\"color: #1d4ed8; text-decoration: underline;\">").concat(linkText, "</a>"));
}
closeModal();
}, [modalData, selectedText, savedRange, executeCommand, closeModal]);
// 이미지 삽입 실행
var insertImage = useCallback(function () {
var url = modalData.url, alt = modalData.alt;
if (url) {
if (savedRange) {
// 저장된 커서 위치 복원
var selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(savedRange);
}
}
executeCommand('insertHTML', "<img src=\"".concat(url, "\" alt=\"").concat(alt || '삽입된 이미지', "\" style=\"max-width: 100%; height: auto;\" />"));
}
closeModal();
}, [modalData, savedRange, executeCommand, closeModal]);
// 동영상 삽입 실행
var insertVideo = useCallback(function () {
var url = modalData.url;
if (url) {
if (savedRange) {
// 저장된 커서 위치 복원
var selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(savedRange);
}
}
if (isYouTubeUrl$1(url)) {
var videoId = getYouTubeVideoId(url);
if (videoId) {
var embedHtml = "\n <div contenteditable=\"false\" style=\"position: relative; width: 100%; height: 0; padding-bottom: 56.25%; margin: 1rem 0;\">\n <iframe \n src=\"https://www.youtube.com/embed/".concat(videoId, "\" \n style=\"position: absolute; top: 0; left: 0; width: 100%; height: 100%;\" \n frameborder=\"0\" \n allowfullscreen>\n </iframe>\n </div>\n <p><br></p>\n ");
executeCommand('insertHTML', embedHtml);
}
}
else {
// 일반 동영상 파일 URL
var videoHtml = "\n <video controls contenteditable=\"false\" style=\"max-width: 100%; height: auto; margin: 1rem 0;\">\n <source src=\"".concat(url, "\" type=\"video/mp4\">\n \uB3D9\uC601\uC0C1\uC744 \uC7AC\uC0DD\uD560 \uC218 \uC5C6\uC2B5\uB2C8\uB2E4.\n </video>\n <p><br></p>\n ");
executeCommand('insertHTML', videoHtml);
}
}
closeModal();
}, [modalData, savedRange, executeCommand, closeModal]);
// 글자 색상 변경 (CSS 기반)
var changeTextColor = useCallback(function (color) {
if (editorRef.current) {
editorRef.current.focus();
var selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
if (selection.isCollapsed) {
// 커서만 있는 경우 - 새로운 span 요소를 생성하여 입력될 텍스트에 스타일 적용
var range = selection.getRangeAt(0);
var span = document.createElement('span');
span.style.color = color;
span.innerHTML = '​'; // 보이지 않는 문자
range.insertNode(span);
// 커서를 span 내부로 이동
range.setStart(span.firstChild, 1);
range.setEnd(span.firstChild, 1);
selection.removeAllRanges();
selection.addRange(range);
}
else {
// 텍스트가 선택된 경우 - 선택된 텍스트를 span으로 감싸고 스타일 적용
var range = selection.getRangeAt(0);
var selectedContent = range.extractContents();
var span = document.createElement('span');
span.style.color = color;
span.appendChild(selectedContent);
range.insertNode(span);
// 선택을 유지
range.selectNodeContents(span);
selection.removeAllRanges();
selection.addRange(range);
}
// 변경사항을 상위로 전달 (DOM 업데이트 후 실행)
setTimeout(function () {
if (onChange && editorRef.current) {
onChange(editorRef.current.innerHTML);
}
}, 0);
}
}
setCurrentTextColor(color);
setShowTextColorPicker(false);
}, [onChange]);
// 배경 색상 변경 (CSS 기반)
var changeBackgroundColor = useCallback(function (color) {
if (editorRef.current) {
editorRef.current.focus();
var selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
if (selection.isCollapsed) {
// 커서만 있는 경우 - 새로운 span 요소를 생성하여 입력될 텍스트에 스타일 적용
var range = selection.getRangeAt(0);
var span = document.createElement('span');
span.style.backgroundColor = color;
span.innerHTML = '​'; // 보이지 않는 문자
range.insertNode(span);
// 커서를 span 내부로 이동
range.setStart(span.firstChild, 1);
range.setEnd(span.firstChild, 1);
selection.removeAllRanges();
selection.addRange(range);
}
else {
// 텍스트가 선택된 경우 - 선택된 텍스트를 span으로 감싸고 스타일 적용
var range = selection.getRangeAt(0);
var selectedContent = range.extractContents();
var span = document.createElement('span');
span.style.backgroundColor = color;
span.appendChild(selectedContent);
range.insertNode(span);
// 선택을 유지
range.selectNodeContents(span);
selection.removeAllRanges();
selection.addRange(range);
}
// 변경사항을 상위로 전달 (DOM 업데이트 후 실행)
setTimeout(function () {
if (onChange && editorRef.current) {
onChange(editorRef.current.innerHTML);
}
}, 0);
}
}
setCurrentBackgroundColor(color);
setShowBackgroundColorPicker(false);
}, [onChange]);
// 구분선 삽입
var insertHorizontalRule = useCallback(function () {
if (savedRange) {
var selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(savedRange);
}
}
executeCommand('insertHTML', '<hr style="border: none; border-top: 1px solid #ccc; margin: 1rem 0;" />');
}, [savedRange, executeCommand]);
// 특수문자 삽입
var insertSpecialChar = useCallback(function (char) {
if (savedRange) {
var selection = window.getSelection();
if (selection) {
selection.removeAllRanges();
selection.addRange(savedRange);
}
}
executeCommand('insertText', char);
setShowSpecialCharPicker(false);
}, [savedRange, executeCommand]);
// 특수문자 팔레트 열기
var openSpecialCharPicker = useCallback(function () {
// 현재 커서 위치를 저장
var selection = window.getSelection();
if (selection && selection.rangeCount > 0) {
var range = selection.getRangeAt(0);
setSavedRange(range.cloneRange());
}
else {
if (editorRef.current) {
editorRef.current.focus();
var newSelection = window.getSelection();
if (newSelection && newSelection.rangeCount > 0) {
var range = newSelection.getRangeAt(0);
setSavedRange(range.cloneRange());
}
}
}
setShowSpecialCharPicker(!showSpecialCharPicker);
setShowTextColorPicker(false);
setShowBackgroundColorPicker(false);
}, [showSpecialCharPicker]);
// 복사 버튼 기능 (상태별로 다른 복사 방식)
var copyToClipboard = useCallback(function () { return __awaiter(void 0, void 0, void 0, function () {
var selection, range, successful, plainText, err_1, fallbackText, fallbackErr_1;
var _a;
return __generator(this, function (_b) {
switch (_b.label) {
case 0:
_b.trys.push([0, 5, , 10]);
if (!htmlMode) return [3 /*break*/, 2];
// HTML 보기 상태: HTML 코드를 복사
return [4 /*yield*/, navigator.clipboard.writeText(htmlContent)];
case 1:
// HTML 보기 상태: HTML 코드를 복사
_b.sent();
return [3 /*break*/, 4];
case 2:
if (!editorRef.current) return [3 /*break*/, 4];
selection = window.getSelection();
range = document.createRange();
range.selectNodeContents(editorRef.current);
selection === null || selection === void 0 ? void 0 : selection.removeAllRanges();
selection === null || selection === void 0 ? void 0 : selection.addRange(range);
successful = document.execCommand('copy');
selection === null || selection === void 0 ? void 0 : selection.removeAllRanges();
if (!!successful) return [3 /*break*/, 4];
plainText = editorRef.current.textContent || '';
return [4 /*yield*/, navigator.clipboard.writeText(plainText)];
case 3:
_b.sent();
_b.label = 4;
case 4:
setShowCopySuccess(true);
setTimeout(function () {
setShowCopySuccess(false);
}, 2000); // 2초 후 사라짐
return [3 /*break*/, 10];
case 5:
err_1 = _b.sent();
console.error('복사 실패:', err_1);
_b.label = 6;
case 6:
_b.trys.push([6, 8, , 9]);
fallbackText = htmlMode ? htmlContent : (((_a = editorRef.current) === null || _a === void 0 ? void 0 : _a.textContent) || '');
return [4 /*yield*/, navigator.clipboard.writeText(fallbackText)];
case 7:
_b.sent();
setShowCopySuccess(true);
setTimeout(function () {
setShowCopySuccess(false);
}, 2000);
return [3 /*break*/, 9];
case 8:
fallbackErr_1 = _b.sent();
console.error(