Files
PrivyDrop/frontend/components/Editor/hooks/useEditorCommands.ts
T
david_bai 7e781631bb chore(ui): clear remaining frontend warnings
Resolve the remaining lint warnings without changing behavior by fixing hook dependency lists, removing the icon naming false positive, and switching the YouTube thumbnail to next/image for compliant rendering.
2026-03-27 17:20:49 +08:00

332 lines
11 KiB
TypeScript

import { useCallback } from "react";
import {
FormatType,
AlignmentType,
FontStyleType,
DOMNodeWithStyle,
StyledElement,
} from "../types";
import { useSelection } from "./useSelection";
import { useStyleManagement } from "./useStyleManagement";
import { removeStyle } from "../utils/textFormatting";
import { handleImageUpload } from "../utils/imageHandling";
import { styleMap } from "../constants";
export const useEditorCommands = (
editorRef: React.RefObject<HTMLDivElement>,
handleChange: () => void
) => {
const getSelection = useSelection();
const { findStyleParent, cleanupSpan } = useStyleManagement(editorRef);
// Format text (bold, italic, underline)
const formatText = useCallback(
(format: FormatType) => {
if (typeof window === "undefined") return;
const selectionInfo = getSelection();
if (!selectionInfo || !selectionInfo.selection.toString()) return;
const { selection, range } = selectionInfo;
const styleParent = findStyleParent(
selection.anchorNode as DOMNodeWithStyle,
styleMap[format]
);
if (styleParent) {
// Remove style
removeStyle(styleParent, format);
} else {
// Add style
const span = document.createElement("span");
switch (format) {
case "bold":
span.style.fontWeight = "bold";
break;
case "italic":
span.style.fontStyle = "italic";
break;
case "underline":
span.style.textDecoration = "underline";
break;
}
// If the selected content is within a span and that span does not have the target style, add the style directly
const parentElement = selection.anchorNode?.parentElement;
if (
parentElement &&
parentElement.tagName === "SPAN" &&
!(parentElement as StyledElement).style[styleMap[format]] &&
parentElement !== editorRef.current
) {
(parentElement as StyledElement).style[styleMap[format]] =
span.style[styleMap[format]];
} else {
// Otherwise, create a new span
span.appendChild(range.extractContents());
range.insertNode(span);
}
}
// Maintain selection
const newRange = document.createRange();
selection.removeAllRanges();
selection.addRange(newRange);
// Update HTML
handleChange();
},
[editorRef, findStyleParent, getSelection, handleChange]
);
// Align text
const alignText = useCallback(
(alignment: AlignmentType) => {
if (!editorRef.current || typeof window === "undefined") return;
const selectionInfo = getSelection();
if (!selectionInfo) return;
// Find the current text node or its container
let textNode = selectionInfo.selection.anchorNode as DOMNodeWithStyle;
// If it is a text node, get its parent element
if (textNode.nodeType === 3) {
textNode = textNode.parentElement as DOMNodeWithStyle;
}
// Search outwards for the outermost style container (e.g., a span with color or size)
let outerContainer = textNode;
while (
outerContainer.parentElement &&
outerContainer.parentElement !== editorRef.current &&
(outerContainer.parentElement as HTMLElement).tagName === "SPAN"
) {
outerContainer = outerContainer.parentElement as DOMNodeWithStyle;
}
// Create or find a div container to handle alignment
let alignmentContainer: HTMLElement;
if (
outerContainer.parentElement === editorRef.current ||
(outerContainer.parentElement as HTMLElement).tagName !== "DIV"
) {
// A new alignment container needs to be created
alignmentContainer = document.createElement("div");
alignmentContainer.style.textAlign = alignment;
// Wrap existing content
outerContainer.parentElement?.insertBefore(
alignmentContainer,
outerContainer
);
alignmentContainer.appendChild(outerContainer);
} else {
// Use the existing alignment container
alignmentContainer = outerContainer.parentElement as HTMLElement;
alignmentContainer.style.textAlign = alignment;
}
// Update HTML
handleChange();
},
[editorRef, getSelection, handleChange]
);
// Set font style
const setFontStyle = useCallback(
(type: FontStyleType, value: string) => {
if (typeof window === "undefined") return;
const selectionInfo = getSelection();
if (!selectionInfo || !selectionInfo.selection.toString()) return;
const { selection, range } = selectionInfo;
// Map style type to actual CSS property name
const stylePropertyMap = {
family: "fontFamily",
size: "fontSize",
color: "color",
};
const styleProperty = stylePropertyMap[type];
// Get the range of the selected content
const rangeContent = range.cloneContents();
// Check if the selected content contains block-level <p> / <div> elements
const containsBlock = Array.from(rangeContent.childNodes).some(
(node) =>
node.nodeType === 1 &&
["P", "DIV"].includes((node as HTMLElement).tagName)
);
if (containsBlock) {
// If the selected content includes block-level elements, iterate through and process the text within each block-level element
const blocks = Array.from(rangeContent.childNodes).filter(
(node) =>
node.nodeType === 1 &&
["P", "DIV"].includes((node as HTMLElement).tagName)
);
blocks.forEach((block) => {
const textNodes = [];
const walker = document.createTreeWalker(
block,
NodeFilter.SHOW_TEXT,
null
);
let node;
while ((node = walker.nextNode())) {
textNodes.push(node);
}
textNodes.forEach((textNode) => {
// Check if the parent element is already a span
const parent = textNode.parentNode as HTMLElement;
if (parent.tagName === "SPAN") {
(parent as StyledElement).style[styleProperty] = value;
} else {
const span = document.createElement("span") as StyledElement;
span.style[styleProperty] = value;
parent.insertBefore(span, textNode);
span.appendChild(textNode);
}
});
});
// Clear the original content and insert new content
range.deleteContents();
range.insertNode(rangeContent);
} else {
// If it's plain text, use the original logic
let styleParent = findStyleParent(
selection.anchorNode as DOMNodeWithStyle,
styleProperty
);
if (styleParent && !["P", "DIV"].includes(styleParent.tagName)) {
if (value === "inherit") {
styleParent.style[styleProperty] = "";
cleanupSpan(styleParent);
} else {
styleParent.style[styleProperty] = value;
}
} else {
// Otherwise, create a new span
const span = document.createElement("span") as StyledElement;
span.style[styleProperty] = value;
span.appendChild(range.extractContents());
range.insertNode(span);
}
}
// Maintain selection
selection.removeAllRanges();
selection.addRange(range);
handleChange();
},
[cleanupSpan, findStyleParent, getSelection, handleChange]
);
// Insert link
const insertLink = useCallback(() => {
const selection = window.getSelection();
let text = "test";
if (selection && !selection.isCollapsed) {
// If there is selected text, use the selected text as the link text
text = selection.toString();
}
// Use a prompt to separate the link and text with a space
const input = prompt(
"Please enter the link address and text (separated by space):",
`https:// ${text}`
);
if (input) {
// Split the input to get the url and text
const [url, ...textParts] = input.split(" ");
const text = textParts.join(" "); // Handle cases where the text may contain spaces
if (url && text) {
const selectionInfo = getSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
const link = document.createElement("a");
link.href = url;
link.textContent = text;
link.target = "_blank";
link.style.color = "#0066cc";
link.style.textDecoration = "underline";
range.deleteContents();
range.insertNode(link);
handleChange();
}
}
}, [getSelection, handleChange]);
// Insert image
const insertImage = useCallback(() => {
const input = document.createElement("input");
input.type = "file";
input.accept = "image/*";
input.onchange = (e: Event) => {
const target = e.target as HTMLInputElement;
const file = target.files?.[0];
if (file) {
const reader = new FileReader();
reader.onload = (event: ProgressEvent<FileReader>) => {
if (!event.target || !event.target.result) return;
const img = document.createElement("img");
img.src = event.target.result as string;
img.style.maxWidth = "100%";
img.style.height = "auto";
img.style.margin = "10px 0";
const selectionInfo = getSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
range.deleteContents();
range.insertNode(img);
handleChange();
};
reader.readAsDataURL(file);
}
};
input.click();
}, [getSelection, handleChange]);
// Insert code block
const insertCodeBlock = useCallback(() => {
const code = prompt("insert code:");
if (!code) return;
const selectionInfo = getSelection();
if (!selectionInfo) return;
const { range } = selectionInfo;
const pre = document.createElement("pre");
const codeElement = document.createElement("code");
pre.style.backgroundColor = "#f6f8fa";
pre.style.padding = "16px";
pre.style.borderRadius = "6px";
pre.style.overflow = "auto";
pre.style.margin = "10px 0";
codeElement.style.fontFamily = "monospace";
codeElement.style.whiteSpace = "pre";
codeElement.textContent = code;
pre.appendChild(codeElement);
range.deleteContents();
range.insertNode(pre);
handleChange();
}, [getSelection, handleChange]);
return {
formatText,
alignText,
setFontStyle,
insertLink,
insertImage,
insertCodeBlock,
};
};