ar-design
Version:
AR Design is a (react | nextjs) ui library.
283 lines (282 loc) • 15.3 kB
JavaScript
"use client";
import "../../../assets/css/components/form/text-editor/styles.css";
import { ARIcon } from "../../icons";
import Button from "../button";
import React, { useEffect, useRef, useState } from "react";
import Utils from "../../../libs/infrastructure/shared/Utils";
import ReactDOM from "react-dom";
const TextEditor = ({ name, value, onChange, dynamicList,
// placeholder,
height,
// multilang,
validation, }) => {
// refs
const _container = useRef(null);
const _arIframe = useRef(null);
const _onChange = useRef(onChange);
const _onChangeTimeOut = useRef(null);
// refs -> Alias Panel
const _target = useRef(null);
const _arAliasPanel = useRef(null);
// states
const [iframe, setIframe] = useState(null);
const [iframeDocument, setIframeDocument] = useState(undefined);
// states -> Data
const [tagged, setTagged] = useState([]);
// states -> Alias Panel
const [atRect, setAtRect] = useState(null);
const [filtered, setFiltered] = useState(null);
// variables
const toolbarButtons = [
{ command: "bold", icon: "Bold", tooltip: `Bold (${Utils.GetOSShortCutIcons()} + B)` },
{ command: "italic", icon: "Italic", tooltip: `Italic (${Utils.GetOSShortCutIcons()} + I)` },
{ command: "underline", icon: "Underline", tooltip: `Underline (${Utils.GetOSShortCutIcons()} + U)` },
{ command: "insertUnorderedList", icon: "BulletList", tooltip: "Bulleted List" },
{ command: "insertOrderedList", icon: "NumberList", tooltip: "Numbered List" },
{ command: "justifyLeft", icon: "TextAlingLeft", tooltip: "Align Left" },
{ command: "justifyCenter", icon: "TextAlingCenter", tooltip: "Align Center" },
{ command: "justifyRight", icon: "TextAlingRight", tooltip: "Align Right" },
];
const _iframeClassName = [];
_iframeClassName.push(...Utils.GetClassName("outlined", !Utils.IsNullOrEmpty(validation?.text) ? "danger" : "light", { radius: "sm" }, undefined, undefined, undefined));
// methods
const execCommand = (command) => {
if (!_arIframe.current)
return;
const iframeDoc = _arIframe.current.contentDocument || _arIframe.current.contentWindow?.document;
if (iframeDoc)
iframeDoc.execCommand(command, true, undefined);
};
const handleFocus = () => _arIframe.current?.classList.add("focused");
const handleBlur = () => {
_arIframe.current?.classList.remove("focused");
// setAtRect(null);
};
const handleMouseDown = () => {
// Resizebar a tıklandığında iframe içerisinde bulunan window'un event listenerı olmadığı için orada resize çalışmayacaktır.
// Bu yüzden önüne bir duvar örüyoruz ve mevcut sayfanın window'unda işlem yapmaya devam ediyor.
const resizeItem = document.createElement("div");
resizeItem.classList.add("ar-text-editor--block-item");
_container.current?.appendChild(resizeItem);
window.addEventListener("mousemove", handleResize);
window.addEventListener("mouseup", () => {
resizeItem.remove();
window.removeEventListener("mousemove", handleResize);
});
};
const handleResize = (event) => {
if (_arIframe.current) {
const rect = _arIframe.current.getBoundingClientRect();
const height = (rect.height += event.movementY);
_arIframe.current.style.height = `${height}px`;
}
};
// methods -> Alias Panel
const handleBackSpaceKeydown = (event) => {
const key = event.key;
if (key === "Backspace" || key === "Delete") {
const selection = _arIframe.current?.contentDocument?.getSelection();
if (!selection || selection.rangeCount === 0)
return;
const range = selection.getRangeAt(0);
// 1. Çoklu seçim varsa: clone edip span'ları bul.
const contents = range.cloneContents();
const multiSpans = contents.querySelectorAll("span[data-tag]");
if (multiSpans.length > 0) {
event.preventDefault();
const tagsToRemove = [];
multiSpans.forEach((span) => {
const tag = span.getAttribute("data-tag");
if (tag)
tagsToRemove.push(tag);
});
// DOM'dan temizle
range.deleteContents();
// State'ten temizle
setTagged((prev) => prev.filter((x) => tagsToRemove.every((tag) => !JSON.stringify(x).includes(tag))));
return;
}
// 2. Tekli seçim: caret bir span içindeyse sil
const node = selection.anchorNode;
const container = node?.parentElement;
if (container?.tagName === "SPAN" && container.dataset.tag) {
event.preventDefault();
const tag = container.dataset.tag;
// DOM'dan sil
container.remove();
// State'ten kaldır
setTagged((prev) => prev.filter((x) => !JSON.stringify(x).includes(tag ?? "")));
}
}
};
// useEffects
useEffect(() => {
// Iframe Document yüklendikten sonra çalışacaktır.
if (!iframeDocument)
return;
const selection = iframeDocument.getSelection();
let range = null;
// Eğer bir seçim (caret) varsa, konumunu kaydet
if (selection && selection.rangeCount > 0)
range = selection.getRangeAt(0);
// Eğer içeriği kendimiz değiştirmedikse ve gelen value farklıysa, içeriği güncelle
if (iframeDocument.body.innerHTML !== value) {
// iframeDocument.body.innerHTML = value || `<p>${placeholder ?? ""}</p>`;
iframeDocument.body.innerHTML = value ?? "";
}
// Cursor (caret) konumunu geri yükle
if (range) {
selection?.removeAllRanges();
selection?.addRange(range);
}
}, [value, iframeDocument]);
useEffect(() => {
// onChange değiştiğinde ref'i güncelle
_onChange.current = onChange;
}, [onChange]);
useEffect(() => {
// Iframe yüklendikten sonra çalışacaktır.
if (!iframe)
return;
const _iframeDocument = iframe.contentDocument || iframe.contentWindow?.document;
if (!_iframeDocument)
return;
setIframeDocument(_iframeDocument);
_iframeDocument.designMode = "on";
// Herhangi bir değişikliği izlemek için MutationObserver kullan
const observer = new MutationObserver((mutationsList) => {
if (_onChangeTimeOut.current)
clearTimeout(_onChangeTimeOut.current);
_onChangeTimeOut.current = setTimeout(() => {
mutationsList.forEach((record) => {
const target = record.target;
_target.current = record.target;
if (dynamicList) {
if (target.nodeType === Node.TEXT_NODE) {
const text = target.textContent ?? "";
const atIndex = text.lastIndexOf(dynamicList?.triggerKey ?? "@");
if (atIndex !== -1) {
const afterAt = text.slice(atIndex + 1); // @ sonrası metin.
const hasWhitespace = /\s/.test(afterAt);
if (!hasWhitespace) {
const selection = _iframeDocument?.getSelection();
if (selection && selection.rangeCount > 0) {
const range = selection.getRangeAt(0).cloneRange();
const rect = range.getBoundingClientRect();
range.collapse(true);
setAtRect(rect);
setFiltered(afterAt);
return;
}
}
}
// Eğer @ yoksa ya da boşluk varsa paneli kapat.
setAtRect(null);
}
}
_iframeDocument?.body.innerHTML === "<br>"
? _onChange.current(undefined)
: _onChange.current(_iframeDocument.body.innerHTML);
});
}, 750);
});
// Observer'ı body üzerinde başlat
observer.observe(_iframeDocument.body, { childList: true, subtree: true, characterData: true, attributes: true });
_iframeDocument.body.addEventListener("focus", handleFocus);
_iframeDocument.body.addEventListener("blur", handleBlur);
if (dynamicList) {
_iframeDocument.body.addEventListener("keydown", handleBackSpaceKeydown);
}
return () => {
observer.disconnect();
_iframeDocument.body.removeEventListener("focus", handleFocus);
_iframeDocument.body.removeEventListener("blur", handleBlur);
if (dynamicList) {
_iframeDocument.body.removeEventListener("keydown", handleBackSpaceKeydown);
}
};
}, [iframe]);
useEffect(() => {
dynamicList?.onTagged && dynamicList?.onTagged(tagged);
}, [tagged]);
useEffect(() => {
if (!_arIframe.current)
return;
setIframe(_arIframe.current);
return () => {
if (iframeDocument) {
iframeDocument.body.removeEventListener("focus", handleFocus);
iframeDocument.body.removeEventListener("blur", handleBlur);
}
};
}, []);
return (React.createElement("div", { ref: _container, className: "ar-text-editor" },
React.createElement("iframe", { ref: _arIframe, name: name, className: _iframeClassName.map((c) => c).join(" "), height: height }),
React.createElement("div", { className: "toolbar" }, toolbarButtons.map(({ command, icon, tooltip }) => (React.createElement(Button, { key: command, type: "button", variant: "borderless", status: "secondary", border: { radius: "none" }, icon: { element: React.createElement(ARIcon, { icon: icon, size: 18, fill: "transparent" }) }, tooltip: {
text: tooltip,
}, onClick: () => execCommand(command) })))),
React.createElement("div", { className: "resize", onMouseDown: handleMouseDown }),
validation?.text && React.createElement("span", { className: "validation" }, validation.text),
dynamicList &&
atRect &&
ReactDOM.createPortal(React.createElement("div", { ref: _arAliasPanel, className: "ar-alias-panel", style: {
top: (_arIframe.current?.getBoundingClientRect().top ?? 0) + atRect.top + 20,
left: (_arIframe.current?.getBoundingClientRect().left ?? 0) + atRect.left,
}, onClick: () => {
setAtRect(null);
} },
React.createElement("ul", null, dynamicList &&
dynamicList.render.items
// .filter((fItem) => !tagged.some((t: T) => JSON.stringify(fItem) === JSON.stringify(t)))
.filter((item) => {
const displayText = item[dynamicList.render.display] ?? "";
return displayText.toLowerCase().includes(filtered.toLowerCase());
})
.map((item, index) => (React.createElement("li", { key: index, onClick: (event) => {
event.stopPropagation();
const selection = iframeDocument?.getSelection();
const target = _target.current;
if (selection && selection.rangeCount > 0 && target && target.nodeType === Node.TEXT_NODE) {
const text = target.textContent ?? "";
const atIndex = text.lastIndexOf(dynamicList?.triggerKey ?? "@");
if (atIndex !== -1) {
const range = selection.getRangeAt(0).cloneRange();
range.setStart(target, atIndex);
range.setEnd(target, text.length);
range.deleteContents();
const itemText = item[dynamicList.render.display] ?? "";
const span = iframeDocument?.createElement("span");
const spaceNode = iframeDocument?.createTextNode(" \u200B");
if (span && spaceNode && iframeDocument) {
span.setAttribute("data-tag", `${itemText}`);
span.style.backgroundColor = "rgba(114, 15, 103, .1)";
span.style.padding = "0 5px";
span.style.borderRadius = "2px";
span.style.color = "#720f67";
span.style.fontWeight = "bold";
span.textContent = `@${itemText}`;
// Yeni bir wrapper fragment oluştur
const fragment = iframeDocument.createDocumentFragment();
fragment.appendChild(span);
fragment.appendChild(spaceNode); // görünmez boş node, ama yazılabilir
range.insertNode(fragment);
// Cursor'u spaceNode’un sonuna yerleştir
const newRange = iframeDocument.createRange();
newRange.setStart(spaceNode, spaceNode.length);
newRange.collapse(true);
selection.removeAllRanges();
selection.addRange(newRange);
// Focus zaten varsa sorun olmayacak
const activeEl = iframeDocument?.activeElement;
activeEl?.focus();
}
setAtRect(null);
setTagged((prev) => {
const exists = prev.some((i) => JSON.stringify(i) === JSON.stringify(item));
return exists ? prev : [...prev, item];
});
}
}
} }, item[dynamicList.render.display] ?? ""))))), document.body)));
};
export default TextEditor;