컴포넌트
서식 편집기
굵게, 목록, 링크 같은 서식을 쓰고 마크다운이나 HTML로 내보내는 편집기예요.
- #arc
- #component
- #editor
- #text
- #markdown
- #html
- #form
1280px
설치
이 명령 한 줄이면 필요한 파일과 패키지가 함께 설치돼요.
터미널터미널터미널bash
npx shadcn@latest add https://dsui.vercel.app/r/rich-text-editor.json파일
components/arc/rich-text-editor/rich-text-editor.module.csscomponents/arc/rich-text-editor/rich-text-editor.tsx
npm 패키지
@hugeicons/core-free-icons@^4.3.5@hugeicons/react@^1.1.10motion@^14.2.0
함께 오는 항목
rich-text-editor.module.cssrich-text-editor.module.cssrich-text-editor.module.csscss
.root {
position: relative;
width: 100%;
container-type: inline-size;
color: var(--foreground);
font-family: var(--font-body);
letter-spacing: var(--tracking-body);
}
/* The writing surface. Everything inside is plain semantic HTML, styled here so pasted and typed content look the same. */
.editor {
min-height: 8rem;
padding: 2px 0;
outline: none;
color: var(--foreground);
font-size: var(--text-base);
line-height: 1.65;
overflow-wrap: break-word;
white-space: pre-wrap;
word-break: break-word;
caret-color: var(--accent);
}
.editor[contenteditable="false"] { caret-color: transparent; }
.editor::selection,
.editor *::selection { background: color-mix(in oklab, var(--accent) 22%, transparent); }
.editor > * { margin: 0; }
.editor > * + * { margin-top: .6em; }
.editor p { margin: 0; }
.editor h1,
.editor h2,
.editor h3 { font-weight: 500; letter-spacing: -.02em; line-height: 1.25; }
.editor h1 { font-size: var(--text-2xl); }
.editor h2 { font-size: var(--text-xl); }
.editor h3 { font-size: var(--text-lg); }
.editor > :is(h1, h2, h3) { margin-top: 1.1em; }
.editor > :is(h1, h2, h3):first-child { margin-top: 0; }
.editor > :is(h1, h2, h3) + * { margin-top: .45em; }
.editor strong { font-weight: 600; }
.editor s { color: var(--text-secondary); text-decoration-thickness: 1px; }
.editor a { color: var(--accent-strong); text-decoration: underline; text-decoration-color: color-mix(in oklab, var(--accent) 45%, transparent); text-decoration-thickness: 1px; text-underline-offset: 3px; cursor: text; }
.editor code {
padding: .12em .38em;
border-radius: 6px;
background: var(--surface-muted);
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: .86em;
}
.editor blockquote { padding-left: 14px; border-left: 2px solid var(--border-strong); color: var(--text-secondary); }
.editor ul,
.editor ol { margin-block: 0; padding-left: 1.45em; }
/* Set the markers explicitly: app resets that strip list styles would otherwise leave lists unmarked. */
.editor ul { list-style: disc; }
.editor ul ul { list-style: circle; }
.editor ol { list-style: decimal; }
.editor li { padding-left: .2em; }
.editor li + li { margin-top: .2em; }
.editor li > :is(ul, ol) { margin-top: .2em; }
.editor li::marker { color: var(--text-muted); }
.editor ol > li::marker { font-variant-numeric: tabular-nums; }
.editor pre {
padding: 12px 14px;
overflow-x: auto;
border: 1px solid var(--border-subtle);
border-radius: 14px;
background: var(--surface-muted);
font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace;
font-size: var(--text-sm);
line-height: 1.6;
tab-size: 2;
white-space: pre;
}
.editor hr { height: 1px; margin-block: 1.1em; border: 0; background: var(--border); }
/* Placeholders: the whole document when empty, and a quiet hint on the empty line holding the caret. */
.editor[data-empty] > :first-child::before,
.editor:focus [data-hint]::before {
float: left;
height: 0;
color: var(--text-muted);
pointer-events: none;
content: var(--placeholder);
}
.editor:focus:not([data-empty]) [data-hint]::before { content: var(--hint); }
/* Floating toolbar. One surface whose size springs between its two faces. */
.toolbar {
position: absolute;
z-index: 40;
translate: -50% -100%;
overflow: hidden;
border: 1px solid var(--float-edge, var(--surface-raised));
border-radius: 16px;
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
}
.toolbar[data-below] { translate: -50% 0; }
.face { position: absolute; top: 0; left: 0; width: max-content; }
.face[inert] { pointer-events: none; }
.row,
.linkRow { display: flex; align-items: center; gap: 2px; padding: 4px; }
.blockTools { display: flex; align-items: center; gap: 2px; }
/* Narrow editors keep the inline marks; block types stay one slash away. */
@container (max-width: 330px) { .blockTools { display: none; } }
.divider { width: 1px; height: 18px; margin: 0 4px; background: var(--border); }
.tool {
display: grid;
width: 32px;
height: 32px;
flex: none;
place-items: center;
padding: 0;
border: 0;
border-radius: 11px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
-webkit-tap-highlight-color: transparent;
transition: background-color var(--duration-fast) var(--ease-standard), color var(--duration-fast) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
}
.tool:active { transform: scale(.92); }
.tool[aria-pressed="true"] { background: var(--accent-subtle); color: var(--accent-strong); }
.tool:focus-visible { background: var(--surface-muted); color: var(--foreground); }
@media (hover: hover) and (pointer: fine) { .tool:hover { background: var(--surface-muted); color: var(--foreground); } .tool[aria-pressed="true"]:hover { background: var(--accent-subtle); color: var(--accent-strong); } }
.linkInput {
width: min(15rem, 60vw);
height: 32px;
min-width: 0;
padding: 0 8px;
border: 0;
background: transparent;
color: var(--foreground);
font: inherit;
font-size: var(--text-sm);
outline: none;
}
.linkInput::placeholder { color: var(--text-muted); }
.apply {
display: grid;
width: 32px;
height: 32px;
flex: none;
place-items: center;
padding: 0;
border: 0;
border-radius: 11px;
background: var(--surface-muted);
color: var(--text-muted);
cursor: pointer;
transition: background-color var(--duration-standard) var(--ease-standard), color var(--duration-standard) var(--ease-standard), transform var(--duration-instant) var(--ease-standard);
}
.apply[data-ready] { background: var(--accent); color: var(--accent-foreground); }
.apply:active { transform: scale(.92); }
/* Slash menu. */
.slash {
position: absolute;
z-index: 40;
width: 240px;
max-width: calc(100% - 8px);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: none;
border: 1px solid var(--float-edge, var(--surface-raised));
border-radius: 18px;
background: var(--surface-raised);
box-shadow: var(--shadow-floating);
}
.slash[data-above] { translate: 0 -100%; }
.slashBody { padding: 6px; }
.slashList { position: relative; display: grid; margin: 0; padding: 0; list-style: none; }
.slashHighlight { position: absolute; top: 0; right: 0; left: 0; border-radius: 12px; background: color-mix(in oklab, var(--foreground) 6.5%, transparent); pointer-events: none; }
.slashOption {
position: relative;
display: flex;
height: 34px;
align-items: center;
gap: 10px;
padding: 0 10px;
border-radius: 12px;
color: var(--text-secondary);
cursor: pointer;
font-size: var(--text-sm);
-webkit-tap-highlight-color: transparent;
transition: color var(--duration-fast) var(--ease-standard);
}
.slashOption[aria-selected="true"] { color: var(--foreground); }
.slashIcon { display: grid; width: 20px; flex: none; place-items: center; }
.slashLabel { min-width: 0; flex: 1; overflow: hidden; font-weight: 500; text-overflow: ellipsis; white-space: nowrap; }
.slashHint { flex: none; color: var(--text-muted); font-family: ui-monospace, "SFMono-Regular", Menlo, Consolas, monospace; font-size: var(--text-xs); }
.srOnly { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); white-space: nowrap; }
rich-text-editor.tsxrich-text-editor.tsxrich-text-editor.tsxtsx
"use client";
import { forwardRef, useCallback, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState } from "react";
import type { ClipboardEvent as ReactClipboardEvent, CSSProperties, FormEvent, KeyboardEvent as ReactKeyboardEvent, ReactNode } from "react";
import { AnimatePresence, animate, motion, useIsPresent, useMotionValue, useTransform } from "motion/react";
import { useReducedMotion } from "../lib/use-reduced-motion";
import type { Transition, Variants } from "motion/react";
import { HugeiconsIcon } from "@hugeicons/react";
import { CodeIcon, CodeSquareIcon, Heading01Icon, Heading02Icon, Heading03Icon, LeftToRightBlockQuoteIcon, LeftToRightListBulletIcon, LeftToRightListNumberIcon, ParagraphIcon, TextBoldIcon, TextItalicIcon, TextStrikethroughIcon, Unlink01Icon } from "@hugeicons/core-free-icons";
import { motionTokens } from "../lib/motion-tokens";
import styles from "./rich-text-editor.module.css";
import { UiIcon } from "../theme/ui-icon";
import { playUiSound } from "../sound/play";
/* ------------------------------------------------------------------------------------------------
* Markdown and HTML. Small, dependency free converters for the subset the editor writes.
* ---------------------------------------------------------------------------------------------- */
export type BlockType = "p" | "h1" | "h2" | "h3" | "ul" | "ol" | "blockquote" | "pre" | "hr";
const ZWSP = /\u200b/g;
const escapeHtml = (value: string) => value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">").replace(/"/g, """);
const safeUrl = (url: string) => /^(https?:|mailto:|tel:|\/|#)/i.test(url.trim());
/** Adds a scheme to bare domains so `example.com/docs` becomes a working link. */
export function normalizeUrl(input: string) {
const url = input.trim();
if (!url) return "";
if (/^[a-z][a-z\d+.-]*:/i.test(url) || url.startsWith("/") || url.startsWith("#")) return url;
if (/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(url)) return `mailto:${url}`;
return `https://${url}`;
}
function inlineMarkdown(text: string) {
const codes: string[] = [];
let out = escapeHtml(text).replace(/`([^`]+)`/g, (_, code: string) => `\u0000${codes.push(code) - 1}\u0000`);
out = out.replace(/\[([^\]]+)\]\(([^)\s]+)\)/g, (_, label: string, url: string) => safeUrl(url) ? `<a href="${url}">${label}</a>` : label);
out = out.replace(/(\*\*|__)(?=\S)([\s\S]*?\S)\1/g, "<strong>$2</strong>");
out = out.replace(/~~(?=\S)([\s\S]*?\S)~~/g, "<s>$1</s>");
out = out.replace(/(^|[^\w*])\*(?=\S)([^*]*?\S)\*(?!\*)/g, "$1<em>$2</em>");
out = out.replace(/(^|[^\w_])_(?=\S)([^_]*?\S)_(?![\w_])/g, "$1<em>$2</em>");
return out.replace(/\u0000(\d+)\u0000/g, (_, index: string) => `<code>${codes[Number(index)]}</code>`);
}
function listMarkdown(lines: string[]) {
const items = lines.map(line => {
const match = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(line)!;
return { indent: match[1]!.replace(/\t/g, " ").length, tag: /\d/.test(match[2]!) ? "ol" : "ul", text: match[3]! };
});
let html = "";
const stack: { indent: number; tag: string }[] = [];
for (const item of items) {
while (stack.length > 1 && item.indent < stack[stack.length - 1]!.indent) html += `</li></${stack.pop()!.tag}>`;
const top = stack[stack.length - 1];
if (!top || item.indent > top.indent) { html += `<${item.tag}><li>`; stack.push({ indent: item.indent, tag: item.tag }); }
else if (top.tag !== item.tag) { html += `</li></${top.tag}><${item.tag}><li>`; top.tag = item.tag; }
else html += "</li><li>";
html += inlineMarkdown(item.text);
}
while (stack.length) html += `</li></${stack.pop()!.tag}>`;
return html;
}
/** Converts Markdown to the editor's HTML: headings (three levels), lists, quotes, fenced code, rules, and inline marks. */
export function markdownToHtml(markdown: string) {
const lines = markdown.replace(/\r\n?/g, "\n").split("\n");
const out: string[] = [];
const paragraph: string[] = [];
const flush = () => { if (paragraph.length) { out.push(`<p>${inlineMarkdown(paragraph.join(" "))}</p>`); paragraph.length = 0; } };
const isItem = (line: string) => /^\s*([-*+]|\d+[.)])\s+/.test(line);
for (let index = 0; index < lines.length;) {
const line = lines[index]!;
if (/^\s*```/.test(line)) {
flush();
const code: string[] = [];
index++;
while (index < lines.length && !/^\s*```/.test(lines[index]!)) code.push(lines[index++]!);
index++;
out.push(`<pre>${escapeHtml(code.join("\n")) || "<br>"}</pre>`);
continue;
}
if (!line.trim()) { flush(); index++; continue; }
const heading = /^(#{1,6})\s+(.*)$/.exec(line);
if (heading) { flush(); const level = Math.min(3, heading[1]!.length); out.push(`<h${level}>${inlineMarkdown(heading[2]!)}</h${level}>`); index++; continue; }
if (/^ {0,3}([-*_])( *\1){2,} *$/.test(line)) { flush(); out.push("<hr>"); index++; continue; }
if (/^\s*>/.test(line)) {
flush();
const quote: string[] = [];
while (index < lines.length && /^\s*>/.test(lines[index]!)) quote.push(lines[index++]!.replace(/^\s*>\s?/, ""));
out.push(`<blockquote>${quote.map(inlineMarkdown).join("<br>")}</blockquote>`);
continue;
}
if (isItem(line)) {
flush();
const items: string[] = [];
while (index < lines.length && isItem(lines[index]!)) items.push(lines[index++]!);
out.push(listMarkdown(items));
continue;
}
paragraph.push(line.trim());
index++;
}
flush();
return out.join("") || "<p><br></p>";
}
function wrapMark(inner: string, mark: string) {
const match = /^(\s*)([\s\S]*?)(\s*)$/.exec(inner)!;
return match[2] ? `${match[1]}${mark}${match[2]}${mark}${match[3]}` : inner;
}
function inlineToMarkdown(node: Node): string {
let out = "";
node.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) { out += (child as Text).data.replace(ZWSP, ""); return; }
if (child.nodeType !== Node.ELEMENT_NODE) return;
const element = child as HTMLElement;
switch (element.nodeName) {
case "BR": out += "\n"; break;
case "STRONG": case "B": out += wrapMark(inlineToMarkdown(element), "**"); break;
case "EM": case "I": out += wrapMark(inlineToMarkdown(element), "_"); break;
case "S": case "STRIKE": case "DEL": out += wrapMark(inlineToMarkdown(element), "~~"); break;
case "CODE": out += `\`${(element.textContent ?? "").replace(ZWSP, "")}\``; break;
case "A": out += `[${inlineToMarkdown(element)}](${element.getAttribute("href") ?? ""})`; break;
case "UL": case "OL": break;
default: out += inlineToMarkdown(element);
}
});
return out;
}
function listToMarkdown(list: Element, indent: string): string {
let number = 1;
return Array.from(list.children).filter(item => item.nodeName === "LI").map(item => {
const marker = list.nodeName === "OL" ? `${number++}. ` : "- ";
const nested = Array.from(item.children).filter(child => child.nodeName === "UL" || child.nodeName === "OL").map(child => listToMarkdown(child, indent + " ".repeat(marker.length)));
return [`${indent}${marker}${inlineToMarkdown(item).trim()}`, ...nested].join("\n");
}).join("\n");
}
function blockToMarkdown(node: Node): string {
if (node.nodeType === Node.TEXT_NODE) return (node as Text).data.replace(ZWSP, "").trim();
if (node.nodeType !== Node.ELEMENT_NODE) return "";
const element = node as HTMLElement;
switch (element.nodeName) {
case "H1": return `# ${inlineToMarkdown(element).trim()}`;
case "H2": return `## ${inlineToMarkdown(element).trim()}`;
case "H3": return `### ${inlineToMarkdown(element).trim()}`;
case "HR": return "---";
case "PRE": return `\`\`\`\n${(element.textContent ?? "").replace(ZWSP, "").replace(/\n$/, "")}\n\`\`\``;
case "UL": case "OL": return listToMarkdown(element, "");
case "BLOCKQUOTE": {
const blocks = Array.from(element.children).some(child => BLOCK_TAGS.has(child.nodeName));
const body = blocks ? Array.from(element.childNodes).map(blockToMarkdown).filter(Boolean).join("\n\n") : inlineToMarkdown(element).trim();
return body.split("\n").map(line => line ? `> ${line}` : ">").join("\n");
}
default: return inlineToMarkdown(element).trim();
}
}
/** Converts editor HTML (or an element) to Markdown. */
export function htmlToMarkdown(input: string | HTMLElement) {
const root = typeof input === "string" ? parseHtml(input) : input;
return Array.from(root.childNodes).map(blockToMarkdown).filter(Boolean).join("\n\n");
}
function parseHtml(html: string) {
const template = document.createElement("template");
template.innerHTML = html;
const root = document.createElement("div");
root.appendChild(template.content);
return root;
}
const insertAfter = (reference: Node, ...nodes: Node[]) => { let at = reference; nodes.forEach(node => { at.parentNode!.insertBefore(node, at.nextSibling); at = node; }); };
const insertBefore = (reference: Node, ...nodes: Node[]) => nodes.forEach(node => reference.parentNode!.insertBefore(node, reference));
const replaceNode = (old: Node, ...nodes: Node[]) => { insertBefore(old, ...nodes); old.parentNode!.removeChild(old); };
const appendNodes = (parent: Node, ...nodes: Node[]) => nodes.forEach(node => parent.appendChild(node));
const INLINE_MAP: Record<string, string> = { B: "strong", STRONG: "strong", I: "em", EM: "em", S: "s", STRIKE: "s", DEL: "s", CODE: "code", A: "a", BR: "br" };
const BLOCK_MAP: Record<string, string> = { P: "p", H1: "h1", H2: "h2", H3: "h3", H4: "h3", H5: "h3", H6: "h3", UL: "ul", OL: "ol", LI: "li", BLOCKQUOTE: "blockquote", PRE: "pre", HR: "hr" };
const DROP = new Set(["SCRIPT", "STYLE", "META", "LINK", "TITLE", "TEMPLATE", "IFRAME", "OBJECT", "EMBED", "SVG", "CANVAS", "IMG", "VIDEO", "AUDIO", "INPUT", "BUTTON", "SELECT", "TEXTAREA", "HEAD"]);
const BLOCK_TAGS = new Set(["P", "H1", "H2", "H3", "LI", "PRE", "BLOCKQUOTE", "DIV", "UL", "OL", "HR"]);
/** Keeps only the tags the editor understands and drops every attribute except safe link targets. Bold and italic spans from other editors become marks. */
export function sanitizeHtml(html: string) {
const source = new DOMParser().parseFromString(html, "text/html").body;
const out = document.createElement("div");
const convert = (from: Node, into: Node) => {
from.childNodes.forEach(child => {
if (child.nodeType === Node.TEXT_NODE) { into.appendChild(document.createTextNode((child as Text).data)); return; }
if (child.nodeType !== Node.ELEMENT_NODE) return;
const element = child as HTMLElement;
if (DROP.has(element.nodeName)) return;
const style = element.getAttribute("style") ?? "";
let tag = INLINE_MAP[element.nodeName] ?? BLOCK_MAP[element.nodeName];
if (element.nodeName === "B" && /font-weight:\s*(normal|[1-4]00)/.test(style)) tag = "";
if (element.nodeName === "SPAN") tag = /font-weight:\s*(bold|[6-9]00)/.test(style) ? "strong" : /font-style:\s*italic/.test(style) ? "em" : /line-through/.test(style) ? "s" : "";
if (!tag) { convert(element, into); return; }
const next = document.createElement(tag);
if (tag === "a") {
const href = element.getAttribute("href") ?? "";
if (!safeUrl(href)) { convert(element, into); return; }
next.setAttribute("href", href);
}
if (tag === "pre") next.textContent = element.textContent ?? "";
else if (tag !== "br" && tag !== "hr") convert(element, next);
into.appendChild(next);
});
};
convert(source, out);
// Inline runs at the top level become paragraphs; whitespace between blocks disappears.
const fixed = document.createElement("div");
let run: HTMLElement | null = null;
Array.from(out.childNodes).forEach(node => {
const block = node.nodeType === Node.ELEMENT_NODE && BLOCK_TAGS.has(node.nodeName);
if (block) { run = null; fixed.appendChild(node); return; }
if (node.nodeType === Node.TEXT_NODE && !(node as Text).data.trim() && !run) return;
if (!run) { run = document.createElement("p"); fixed.appendChild(run); }
run.appendChild(node);
});
fixed.querySelectorAll("li > p").forEach(paragraph => replaceNode(paragraph, ...Array.from(paragraph.childNodes)));
// The editor keeps typed whitespace, so formatting whitespace between blocks and list items has to go.
[fixed, ...Array.from(fixed.querySelectorAll("ul, ol, blockquote, li"))].forEach(parent => Array.from(parent.childNodes).forEach(node => {
if (isText(node) && !node.data.trim() && (parent === fixed || parent.nodeName === "UL" || parent.nodeName === "OL" || (node.previousSibling && BLOCK_TAGS.has(node.previousSibling.nodeName)) || (node.nextSibling && BLOCK_TAGS.has(node.nextSibling.nodeName)))) node.remove();
}));
return fixed.innerHTML;
}
/* ------------------------------------------------------------------------------------------------
* DOM helpers. Positions are stored as character offsets where every block start and line break
* counts as one character, so the caret survives any rewrite that keeps the text.
* ---------------------------------------------------------------------------------------------- */
const COUNTED = new Set(["P", "H1", "H2", "H3", "LI", "PRE", "BLOCKQUOTE", "DIV"]);
const isElement = (node: Node | null): node is HTMLElement => !!node && node.nodeType === Node.ELEMENT_NODE;
const isText = (node: Node | null): node is Text => !!node && node.nodeType === Node.TEXT_NODE;
function pointToOffset(root: Node, container: Node, offset: number) {
let count = 0, found = -1;
const visit = (node: Node) => {
if (found >= 0) return;
if (isText(node)) { if (node === container) found = count + offset; else count += node.data.length; return; }
if (!isElement(node)) return;
if (node !== root && COUNTED.has(node.nodeName)) count += 1;
if (node.nodeName === "BR" || node.nodeName === "HR") count += 1;
const children = node.childNodes;
for (let index = 0; index < children.length; index++) {
if (node === container && index === offset) { found = count; return; }
visit(children[index]!);
if (found >= 0) return;
}
if (node === container) found = count;
};
visit(root);
return found < 0 ? count : found;
}
function offsetToPoint(root: Node, target: number): [Node, number] {
let count = 0;
let result: [Node, number] | null = null;
const visit = (node: Node) => {
if (result) return;
if (isText(node)) {
if (target <= count + node.data.length) { result = [node, Math.max(0, target - count)]; return; }
count += node.data.length;
return;
}
if (!isElement(node)) return;
if (node !== root && COUNTED.has(node.nodeName)) count += 1;
if (node.nodeName === "BR" || node.nodeName === "HR") {
if (count === target && node.parentNode) { result = [node.parentNode, Array.prototype.indexOf.call(node.parentNode.childNodes, node)]; return; }
count += 1;
return;
}
node.childNodes.forEach(visit);
if (!result && count === target && node !== root && COUNTED.has(node.nodeName)) result = [node, node.childNodes.length];
};
visit(root);
if (result) return result;
const last = root.lastChild;
return last && isElement(last) && last.nodeName !== "HR" ? [last, last.childNodes.length] : [root, root.childNodes.length];
}
type Offsets = { start: number; end: number };
function readOffsets(root: HTMLElement): Offsets | null {
const selection = window.getSelection();
if (!selection?.rangeCount) return null;
const range = selection.getRangeAt(0);
if (!root.contains(range.startContainer) || !root.contains(range.endContainer)) return null;
return { start: pointToOffset(root, range.startContainer, range.startOffset), end: pointToOffset(root, range.endContainer, range.endOffset) };
}
function writeOffsets(root: HTMLElement, offsets: Offsets | null) {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
if (!offsets) { range.selectNodeContents(root.lastElementChild ?? root); range.collapse(false); }
else {
const [startNode, startOffset] = offsetToPoint(root, offsets.start);
const [endNode, endOffset] = offsets.end === offsets.start ? [startNode, startOffset] : offsetToPoint(root, offsets.end);
range.setStart(startNode, startOffset);
range.setEnd(endNode, endOffset);
}
selection.removeAllRanges();
selection.addRange(range);
}
/** The visible band of the nearest scrolling or clipping ancestor, so floating layers flip before they get cut off. */
function clipBand(element: HTMLElement) {
let top = 0, bottom = window.innerHeight;
for (let node = element.parentElement; node; node = node.parentElement) {
const style = getComputedStyle(node);
if (style.overflowY !== "visible" || style.overflowX !== "visible") {
const rect = node.getBoundingClientRect();
top = Math.max(top, rect.top);
bottom = Math.min(bottom, rect.bottom);
}
}
return { top, bottom };
}
function placeCaret(node: Node, offset: number) {
const selection = window.getSelection();
if (!selection) return;
const range = document.createRange();
range.setStart(node, offset);
range.collapse(true);
selection.removeAllRanges();
selection.addRange(range);
}
function placeAtStart(element: HTMLElement) {
const walker = document.createTreeWalker(element, NodeFilter.SHOW_TEXT);
const first = walker.nextNode();
if (first) placeCaret(first, 0); else placeCaret(element, 0);
}
/** The nearest block around a node: a paragraph, heading, list item, quote, or code block. */
function blockAt(root: HTMLElement, node: Node | null): HTMLElement | null {
let current: Node | null = node;
while (current && current !== root) {
if (isElement(current) && COUNTED.has(current.nodeName)) return current;
current = current.parentNode;
}
return null;
}
const visibleText = (element: Node) => (element.textContent ?? "").replace(ZWSP, "");
function ensureFilled(element: HTMLElement) {
if (element.nodeName === "HR") return;
if (!visibleText(element) && !element.querySelector("br, hr, ul, ol")) element.appendChild(document.createElement("br"));
}
function textBefore(block: HTMLElement, range: Range) {
const before = document.createRange();
before.setStart(block, 0);
before.setEnd(range.startContainer, range.startOffset);
return before.toString().replace(ZWSP, "").replace(/\u00a0/g, " ");
}
function atBlockStart(block: HTMLElement, range: Range) {
if (!range.collapsed) return false;
const before = document.createRange();
before.setStart(block, 0);
before.setEnd(range.startContainer, range.startOffset);
return !before.toString().replace(ZWSP, "") && !before.cloneContents().querySelector("br, hr");
}
function moveChildren(from: Node, to: Node) {
while (from.firstChild) to.appendChild(from.firstChild);
}
/** Plain text of a block, with line breaks for `<br>`, for moving content into a code block. */
function plainText(element: HTMLElement) {
let out = "";
const walk = (node: Node) => node.childNodes.forEach(child => {
if (isText(child)) out += child.data.replace(ZWSP, "");
else if (child.nodeName === "BR") out += "\n";
else walk(child);
});
walk(element);
return out.replace(/\n$/, "");
}
function linesToNodes(text: string) {
const nodes: Node[] = [];
text.replace(/\n$/, "").split("\n").forEach((line, index) => {
if (index) nodes.push(document.createElement("br"));
if (line) nodes.push(document.createTextNode(line));
});
return nodes;
}
function mergeAdjacentLists(list: HTMLElement) {
const next = list.nextElementSibling;
if (next && next.nodeName === list.nodeName) { moveChildren(next, list); next.remove(); }
const previous = list.previousElementSibling;
if (previous && previous.nodeName === list.nodeName) { moveChildren(list, previous); list.remove(); return previous as HTMLElement; }
return list;
}
/** Moves a list item one level out. A top level item leaves the list and becomes a paragraph between its halves. */
function outdent(item: HTMLElement): HTMLElement {
const list = item.parentElement!;
const parentItem = list.parentElement;
const following: Node[] = [];
for (let node = item.nextSibling; node; node = node.nextSibling) following.push(node);
if (parentItem?.nodeName === "LI") {
if (following.length) {
const sub = document.createElement(list.nodeName.toLowerCase());
following.forEach(node => sub.appendChild(node));
item.appendChild(sub);
}
insertAfter(parentItem, item);
if (!list.children.length) list.remove();
return item;
}
const paragraph = document.createElement("p");
const nested: Node[] = [];
Array.from(item.childNodes).forEach(child => { if (child.nodeName === "UL" || child.nodeName === "OL") nested.push(child); else paragraph.appendChild(child); });
ensureFilled(paragraph);
const tail = following.length || nested.length ? document.createElement(list.nodeName.toLowerCase()) : null;
if (tail) {
nested.forEach(sub => sub.childNodes.length && Array.from(sub.childNodes).forEach(node => tail.appendChild(node)));
following.forEach(node => tail.appendChild(node));
}
insertAfter(list, paragraph);
if (tail) insertAfter(paragraph, tail);
item.remove();
if (!list.children.length) list.remove();
return paragraph;
}
function indent(item: HTMLElement) {
const previous = item.previousElementSibling as HTMLElement | null;
if (!previous || previous.nodeName !== "LI") return false;
const listTag = item.parentElement!.nodeName;
let sub = previous.lastElementChild as HTMLElement | null;
if (!sub || (sub.nodeName !== "UL" && sub.nodeName !== "OL")) { sub = document.createElement(listTag.toLowerCase()); previous.appendChild(sub); }
sub.appendChild(item);
return true;
}
/** Turns one block into another type, keeping its content. Choosing the type a block already has turns it back into a paragraph. */
function convertBlock(block: HTMLElement, type: Exclude<BlockType, "hr">): HTMLElement {
if (block.nodeName === "LI") {
const list = block.parentElement!;
if (type === "ul" || type === "ol") {
if (list.nodeName.toLowerCase() === type) return outdent(block);
const swapped = document.createElement(type);
moveChildren(list, swapped);
replaceNode(list, swapped);
return block;
}
const paragraph = outdent(block);
return paragraph.nodeName === "P" && type !== "p" ? convertBlock(paragraph, type) : paragraph;
}
if (type === "ul" || type === "ol") {
const item = document.createElement("li");
if (block.nodeName === "PRE") appendNodes(item, ...linesToNodes(block.textContent ?? "")); else moveChildren(block, item);
ensureFilled(item);
const list = document.createElement(type);
list.appendChild(item);
replaceNode(block, list);
mergeAdjacentLists(list);
return item;
}
const tag = block.nodeName.toLowerCase() === type ? "p" : type;
const next = document.createElement(tag);
if (tag === "pre") next.textContent = plainText(block);
else if (block.nodeName === "PRE") appendNodes(next, ...linesToNodes(block.textContent ?? ""));
else moveChildren(block, next);
ensureFilled(next);
replaceNode(block, next);
return next;
}
/** Makes sure a paragraph follows a block the caret cannot type after, such as a rule or a code block at the end. */
function paragraphAfter(block: Element) {
const next = block.nextElementSibling;
if (next && next.nodeName === "P") return next as HTMLElement;
const paragraph = document.createElement("p");
paragraph.appendChild(document.createElement("br"));
insertAfter(block, paragraph);
return paragraph;
}
/** Repairs what browsers leave behind: styled spans from merges, stray inline content at the top level, and an empty root. */
function tidy(root: HTMLElement) {
const loose = Array.from(root.childNodes).some(node => (isText(node) && node.data.trim()) || (isElement(node) && !BLOCK_TAGS.has(node.nodeName)));
const dirty = loose || !root.firstChild || root.querySelector("span, font, [style], div, b, i, strike");
if (!dirty) return;
const offsets = readOffsets(root);
root.querySelectorAll("[style]").forEach(element => {
const style = element.getAttribute("style") ?? "";
if (element.nodeName === "SPAN" && /font-weight:\s*(bold|[6-9]00)/.test(style)) replaceNode(element, Object.assign(document.createElement("strong"), { innerHTML: element.innerHTML }));
else element.removeAttribute("style");
});
const rename = (selector: string, tag: string) => root.querySelectorAll(selector).forEach(element => {
const next = document.createElement(tag);
moveChildren(element, next);
replaceNode(element, next);
});
rename("b", "strong");
rename("i", "em");
rename("strike", "s");
rename(":scope > div", "p");
root.querySelectorAll("span, font, div").forEach(element => replaceNode(element, ...Array.from(element.childNodes)));
let run: HTMLElement | null = null;
Array.from(root.childNodes).forEach(node => {
if (isElement(node) && BLOCK_TAGS.has(node.nodeName)) { run = null; return; }
if (isText(node) && !node.data.trim() && !run) { node.remove(); return; }
if (!run) { run = document.createElement("p"); insertBefore(node, run); }
run.appendChild(node);
});
if (!root.firstChild) root.innerHTML = "<p><br></p>";
if (root.contains(document.activeElement) || document.activeElement === root) writeOffsets(root, offsets);
}
/** A clean copy for output: canonical tags, no attributes but links, no invisible helpers, no trailing empty lines. */
function cleanClone(root: HTMLElement) {
const clone = root.cloneNode(true) as HTMLElement;
clone.querySelectorAll("*").forEach(element => {
Array.from(element.attributes).forEach(attribute => { if (!(element.nodeName === "A" && attribute.name === "href")) element.removeAttribute(attribute.name); });
});
const walker = document.createTreeWalker(clone, NodeFilter.SHOW_TEXT);
const empty: Text[] = [];
for (let node = walker.nextNode() as Text | null; node; node = walker.nextNode() as Text | null) {
node.data = node.data.replace(ZWSP, "");
if (!node.data) empty.push(node);
}
empty.forEach(node => node.remove());
clone.querySelectorAll("p, h1, h2, h3, li, blockquote").forEach(block => {
const last = block.lastChild;
if (last?.nodeName === "BR" && visibleText(block)) last.remove();
});
clone.querySelectorAll("pre").forEach(pre => { pre.textContent = (pre.textContent ?? "").replace(/\n$/, ""); });
while (clone.lastElementChild && clone.lastElementChild.nodeName !== "HR" && !visibleText(clone.lastElementChild) && clone.children.length > 1) clone.lastElementChild.remove();
return clone;
}
function plainOutput(clone: HTMLElement) {
return Array.from(clone.querySelectorAll("p, h1, h2, h3, li, blockquote, pre")).filter(block => !block.querySelector("p, li")).map(block => (block.textContent ?? "").trim()).filter(Boolean).join("\n");
}
/* ------------------------------------------------------------------------------------------------
* Component
* ---------------------------------------------------------------------------------------------- */
export interface RichTextValue {
html: string;
markdown: string;
text: string;
empty: boolean;
}
export interface RichTextEditorHandle {
focus: () => void;
getHTML: () => string;
getMarkdown: () => string;
getText: () => string;
setHTML: (html: string) => void;
setMarkdown: (markdown: string) => void;
clear: () => void;
undo: () => void;
redo: () => void;
element: HTMLDivElement | null;
}
export interface RichTextEditorProps {
/** Controlled HTML. The editor only rewrites its content when this differs from what it last emitted. */
value?: string;
defaultValue?: string;
/** Initial content as Markdown, used when no HTML value is given. */
defaultMarkdown?: string;
onChange?: (value: RichTextValue) => void;
/** Reports whether undo and redo are available, for your own toolbar. */
onHistoryChange?: (state: { canUndo: boolean; canRedo: boolean }) => void;
placeholder?: string;
/** The hint on an empty line while typing. */
blockHint?: string;
readOnly?: boolean;
autoFocus?: boolean;
"aria-label"?: string;
className?: string;
style?: CSSProperties;
}
type Formats = { bold: boolean; italic: boolean; strike: boolean; code: boolean; link: string | null; block: string };
type SlashItem = { type: BlockType; label: string; hint: string; keywords: string; icon: ReactNode };
type Slash = { node: Text; offset: number; query: string; x: number; y: number; above: boolean; max: number };
type Snapshot = { html: string; selection: Offsets | null; kind: "type" | "command"; at: number };
const icon = { size: 16, strokeWidth: 1.75, "aria-hidden": true } as const;
const SLASH_ITEMS: SlashItem[] = [
{ type: "p", label: "본문", hint: "", keywords: "paragraph plain body", icon: <HugeiconsIcon icon={ParagraphIcon} {...icon} /> },
{ type: "h1", label: "제목 1", hint: "#", keywords: "title h1 large", icon: <HugeiconsIcon icon={Heading01Icon} {...icon} /> },
{ type: "h2", label: "제목 2", hint: "##", keywords: "subtitle h2 section", icon: <HugeiconsIcon icon={Heading02Icon} {...icon} /> },
{ type: "h3", label: "제목 3", hint: "###", keywords: "h3 small", icon: <HugeiconsIcon icon={Heading03Icon} {...icon} /> },
{ type: "ul", label: "글머리 기호 목록", hint: "-", keywords: "bullet unordered ul points", icon: <HugeiconsIcon icon={LeftToRightListBulletIcon} {...icon} /> },
{ type: "ol", label: "번호 목록", hint: "1.", keywords: "ordered ol steps numbers", icon: <HugeiconsIcon icon={LeftToRightListNumberIcon} {...icon} /> },
{ type: "blockquote", label: "인용", hint: ">", keywords: "blockquote citation callout", icon: <HugeiconsIcon icon={LeftToRightBlockQuoteIcon} {...icon} /> },
{ type: "pre", label: "코드 블록", hint: "```", keywords: "pre snippet monospace", icon: <HugeiconsIcon icon={CodeSquareIcon} {...icon} /> },
{ type: "hr", label: "구분선", hint: "---", keywords: "rule hr line separator", icon: <UiIcon iconRole="minus" {...icon} /> },
];
const { spring, duration, ease, blur } = motionTokens;
const enter = [...ease.enter] as [number, number, number, number];
const standard = [...ease.standard] as [number, number, number, number];
const physical = (visualDuration: number, bounce: number): Transition => {
const root = 2 * Math.PI / (visualDuration * 1.2);
return { type: "spring", stiffness: root * root, damping: 2 * (1 - bounce) * root, mass: 1 };
};
const MORPH = physical(spring.morph.visualDuration, .12), GLIDE = physical(.3, .08), GROW = physical(spring.smooth.visualDuration, 0);
const TRAVEL = 18;
const faceVariants: Variants = {
hidden: (direction: number) => ({ opacity: 0, x: direction * TRAVEL, filter: `blur(${blur.subtle}px)` }),
shown: { opacity: 1, x: 0, filter: "blur(0px)", transition: { x: MORPH, opacity: { duration: motionTokens.duration.standard, ease: enter, delay: .03 }, filter: { duration: motionTokens.duration.standard, ease: enter, delay: .03 } } },
gone: (direction: number) => ({ opacity: 0, x: direction * -TRAVEL, filter: `blur(${blur.subtle}px)`, transition: { x: MORPH, opacity: { duration: motionTokens.duration.instant, ease: standard }, filter: { duration: motionTokens.duration.instant, ease: standard } } }),
};
const fadeVariants: Variants = { hidden: { opacity: 0 }, shown: { opacity: 1, transition: { duration: motionTokens.duration.instant } }, gone: { opacity: 0, transition: { duration: motionTokens.duration.instant } } };
/** A toolbar face reports its natural size while current, so the shared surface can spring to it. */
function Face({ direction, reduced, onSize, children }: { direction: number; reduced: boolean; onSize: (width: number, height: number) => void; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null);
const present = useIsPresent();
useLayoutEffect(() => {
const node = ref.current;
if (!node || !present) return;
const report = () => onSize(node.offsetWidth, node.offsetHeight);
report();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(report);
observer.observe(node);
return () => observer.disconnect();
}, [onSize, present]);
return <motion.div ref={ref} className={styles.face} custom={direction} variants={reduced ? fadeVariants : faceVariants} initial="hidden" animate="shown" exit="gone" inert={!present || undefined}>{children}</motion.div>;
}
function ToolButton({ label, pressed, shortcut, onPress, children }: { label: string; pressed: boolean; shortcut?: string; onPress: () => void; children: ReactNode }) {
return <button type="button" className={styles.tool} aria-label={label} aria-pressed={pressed} title={shortcut ? `${label} (${shortcut})` : label} onMouseDown={event => event.preventDefault()} onClick={onPress}>{children}</button>;
}
/**
* A light rich text editor on contentEditable. Markdown shortcuts as you type, a floating selection toolbar that morphs into a link field,
* a slash menu for blocks, its own undo history, and HTML plus Markdown output. No dependencies beyond Motion and icons.
*/
export const RichTextEditor = forwardRef<RichTextEditorHandle, RichTextEditorProps>(function RichTextEditor({
value, defaultValue, defaultMarkdown, onChange, onHistoryChange, placeholder = "내용을 입력해 주세요", blockHint = "/를 입력하면 블록을 고를 수 있어요",
readOnly = false, autoFocus = false, "aria-label": ariaLabel = "편집기", className, style,
}, ref) {
const reduced = !!useReducedMotion();
const uid = useId();
const rootRef = useRef<HTMLDivElement>(null);
const editorRef = useRef<HTMLDivElement>(null);
const busy = useRef(false);
const dragging = useRef(false);
const lastInput = useRef("");
const lastEmitted = useRef<string | null>(null);
const history = useRef<{ stack: Snapshot[]; index: number }>({ stack: [], index: -1 });
const callbacks = useRef({ onChange, onHistoryChange });
useEffect(() => { callbacks.current = { onChange, onHistoryChange }; }, [onChange, onHistoryChange]);
const [empty, setEmpty] = useState(true);
const [formats, setFormats] = useState<Formats>({ bold: false, italic: false, strike: false, code: false, link: null, block: "p" });
const [bar, setBar] = useState<{ x: number; y: number; below: boolean } | null>(null);
const [face, setFace] = useState<"format" | "link">("format");
const [linkDraft, setLinkDraft] = useState("");
const [slash, setSlash] = useState<Slash | null>(null);
const [slashActive, setSlashActive] = useState(0);
const [announce, setAnnounce] = useState("");
const faceRef = useRef(face);
const slashRef = useRef(slash);
const savedRange = useRef<Range | null>(null);
const linkInputRef = useRef<HTMLInputElement>(null);
const hinted = useRef<HTMLElement | null>(null);
useEffect(() => { faceRef.current = face; }, [face]);
useEffect(() => { slashRef.current = slash; }, [slash]);
/* Output and history. */
const emit = useCallback(() => {
const editor = editorRef.current;
if (!editor) return;
const clone = cleanClone(editor);
const html = clone.innerHTML;
const text = plainOutput(clone);
const isEmpty = !text && !clone.querySelector("hr");
lastEmitted.current = html;
setEmpty(isEmpty);
callbacks.current.onChange?.({ html, markdown: htmlToMarkdown(clone), text, empty: isEmpty });
}, []);
const reportHistory = useCallback(() => {
const { stack, index } = history.current;
callbacks.current.onHistoryChange?.({ canUndo: index > 0, canRedo: index < stack.length - 1 });
}, []);
const record = useCallback((kind: Snapshot["kind"], boundary = false) => {
const editor = editorRef.current;
if (!editor) return;
const snap: Snapshot = { html: editor.innerHTML.replace(/ data-hint=""/g, ""), selection: readOffsets(editor), kind, at: Date.now() };
const state = history.current;
const top = state.stack[state.index];
if (top && top.html === snap.html) { top.selection = snap.selection; return; }
if (kind === "type" && top?.kind === "type" && state.index > 0 && snap.at - top.at < 1000 && !boundary) {
state.stack[state.index] = { ...snap, at: top.at };
} else {
state.stack = state.stack.slice(0, state.index + 1);
state.stack.push(snap);
if (state.stack.length > 300) state.stack.shift();
state.index = state.stack.length - 1;
}
reportHistory();
}, [reportHistory]);
const restore = useCallback((snap: Snapshot) => {
const editor = editorRef.current;
if (!editor) return;
editor.innerHTML = snap.html;
hinted.current = null;
editor.focus({ preventScroll: true });
writeOffsets(editor, snap.selection);
emit();
reportHistory();
}, [emit, reportHistory]);
const undo = useCallback(() => {
const state = history.current;
if (state.index <= 0) return;
state.index -= 1;
setSlash(null);
restore(state.stack[state.index]!);
setAnnounce("되돌렸어요");
}, [restore]);
const redo = useCallback(() => {
const state = history.current;
if (state.index >= state.stack.length - 1) return;
state.index += 1;
setSlash(null);
restore(state.stack[state.index]!);
setAnnounce("다시 실행했어요");
}, [restore]);
/* Selection driven chrome: block hint, active formats, the slash query, and the toolbar. */
const sync = useCallback(() => {
const editor = editorRef.current, root = rootRef.current;
if (!editor || !root || busy.current) return;
const selection = window.getSelection();
const inside = !!selection?.rangeCount && editor.contains(selection.anchorNode);
const range = inside ? selection!.getRangeAt(0) : null;
const block = range ? blockAt(editor, range.startContainer) : null;
const hint = range?.collapsed && block && !visibleText(block) && block.nodeName !== "PRE" ? block : null;
if (hinted.current !== hint) {
hinted.current?.removeAttribute("data-hint");
hint?.setAttribute("data-hint", "");
hinted.current = hint;
}
if (!range) {
if (faceRef.current !== "link") setBar(null);
setSlash(null);
return;
}
const start = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;
const within = (selector: string) => { const found = start?.closest(selector); return !!found && editor.contains(found); };
const anchor = start?.closest("a");
const listItem = block?.nodeName === "LI" ? block.parentElement?.nodeName.toLowerCase() ?? "ul" : null;
const next: Formats = {
bold: within("strong, b"), italic: within("em, i"), strike: within("s, strike, del"),
code: within("code") && !within("pre"),
link: anchor && editor.contains(anchor) ? anchor.getAttribute("href") : null,
block: listItem ?? block?.nodeName.toLowerCase() ?? "p",
};
setFormats(current => (Object.keys(next) as (keyof Formats)[]).every(key => current[key] === next[key]) ? current : next);
const open = slashRef.current;
if (open) {
const valid = range.collapsed && range.startContainer === open.node && range.startOffset > open.offset && open.node.data[open.offset] === "/";
const query = valid ? open.node.data.slice(open.offset + 1, range.startOffset) : "";
if (!valid || query.length > 24 || /^\s/.test(query) || /\s\s/.test(query)) setSlash(null);
else if (query !== open.query) setSlash({ ...open, query });
}
const inCode = !!start?.closest("pre");
if (range.collapsed || dragging.current || inCode || slashRef.current || readOnly) {
if (faceRef.current !== "link") setBar(null);
return;
}
const rects = range.getClientRects();
const rect = rects.length ? rects[0]! : range.getBoundingClientRect();
const whole = range.getBoundingClientRect();
const box = root.getBoundingClientRect();
const below = rect.top - clipBand(root).top < 60;
setBar({ x: whole.left + whole.width / 2 - box.left, y: below ? whole.bottom - box.top + 8 : rect.top - box.top - 8, below });
}, [readOnly]);
const run = useCallback((change: () => void) => {
const editor = editorRef.current;
if (!editor) return;
busy.current = true;
try { change(); tidy(editor); } finally { busy.current = false; }
record("command");
emit();
sync();
}, [emit, record, sync]);
/* Content in and out. */
const load = useCallback((html: string) => {
const editor = editorRef.current;
if (!editor) return;
editor.innerHTML = sanitizeHtml(html) || "<p><br></p>";
editor.querySelectorAll("p, h1, h2, h3, li, blockquote, pre").forEach(block => ensureFilled(block as HTMLElement));
hinted.current = null;
}, []);
const initial = useRef({ value, defaultValue, defaultMarkdown, autoFocus });
useLayoutEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const start = initial.current;
load(start.value ?? start.defaultValue ?? (start.defaultMarkdown !== undefined ? markdownToHtml(start.defaultMarkdown) : "<p><br></p>"));
history.current = { stack: [{ html: editor.innerHTML, selection: null, kind: "command", at: 0 }], index: 0 };
emit();
reportHistory();
if (start.autoFocus) { editor.focus(); writeOffsets(editor, null); }
}, [emit, load, reportHistory]);
useEffect(() => {
if (value === undefined || value === lastEmitted.current) return;
load(value);
record("command");
emit();
}, [emit, load, record, value]);
/* Markdown shortcuts, applied right after the character that completes them. */
const shortcut = useCallback((data: string | null) => {
const editor = editorRef.current;
const selection = window.getSelection();
if (!editor || !data || !selection?.rangeCount) return false;
const range = selection.getRangeAt(0);
if (!range.collapsed) return false;
const block = blockAt(editor, range.startContainer);
if (!block || block.nodeName === "PRE") return false;
const before = textBefore(block, range);
const plainBlock = block.nodeName === "P" || block.nodeName === "DIV";
if (data === " " && plainBlock && block.parentElement === editor) {
const rule: [RegExp, Exclude<BlockType, "hr">][] = [[/^# $/, "h1"], [/^## $/, "h2"], [/^### $/, "h3"], [/^[-*+] $/, "ul"], [/^\d+[.)] $/, "ol"], [/^> $/, "blockquote"]];
const match = rule.find(([pattern]) => pattern.test(before));
if (match) {
const prefix = document.createRange();
prefix.setStart(block, 0);
prefix.setEnd(range.startContainer, range.startOffset);
prefix.deleteContents();
const next = convertBlock(block, match[1]);
placeAtStart(next);
return true;
}
}
if (data === "-" && plainBlock && block.parentElement === editor && before === "---" && visibleText(block) === "---") {
const rule = document.createElement("hr");
replaceNode(block, rule);
placeAtStart(paragraphAfter(rule));
return true;
}
if (data === "`" && plainBlock && block.parentElement === editor && before === "```" && visibleText(block) === "```") {
block.textContent = "";
const next = convertBlock(block, "pre");
placeAtStart(next);
return true;
}
const node = range.startContainer;
if (!isText(node) || node.parentElement?.closest("code")) return false;
const text = node.data.slice(0, range.startOffset);
const inline: [RegExp, string][] = [
[/(\*\*|__)([^*_\s](?:[^*_]*[^*_\s])?)\1$/, "strong"],
[/~~([^~\s](?:[^~]*[^~\s])?)~~$/, "s"],
[/`([^`]+)`$/, "code"],
[/(?:^|[^*\w])(\*([^*\s](?:[^*]*[^*\s])?)\*)$/, "em"],
[/(?:^|[^_\w])(_([^_\s](?:[^_]*[^_\s])?)_)$/, "em"],
];
for (const [pattern, tag] of inline) {
const match = pattern.exec(text);
if (!match) continue;
const whole = tag === "em" ? match[1]! : match[0];
const inner = tag === "em" ? match[2]! : tag === "code" ? match[1]! : tag === "s" ? match[1]! : match[2]!;
const from = range.startOffset - whole.length;
node.splitText(range.startOffset);
const middle = node.splitText(from);
const element = document.createElement(tag);
element.textContent = inner;
replaceNode(middle, element);
// A zero width space after the mark keeps the next keystroke outside it; output strips it.
const exit = document.createTextNode("\u200b");
insertAfter(element, exit);
placeCaret(exit, 1);
return true;
}
return false;
}, []);
const openSlashIfTyped = useCallback((data: string | null) => {
const editor = editorRef.current, root = rootRef.current;
const selection = window.getSelection();
if (data !== "/" || !editor || !root || !selection?.rangeCount) return;
const range = selection.getRangeAt(0);
const node = range.startContainer;
if (!range.collapsed || !isText(node) || node.parentElement?.closest("pre, code")) return;
const offset = range.startOffset - 1;
if (node.data[offset] !== "/" || (offset > 0 && !/[\s\u00a0\u200b]/.test(node.data[offset - 1]!))) return;
const mark = document.createRange();
mark.setStart(node, offset);
mark.setEnd(node, offset + 1);
const rect = mark.getBoundingClientRect(), box = root.getBoundingClientRect();
const band = clipBand(root);
const above = band.bottom - rect.bottom < 330 && rect.top - band.top > band.bottom - rect.bottom;
const room = above ? rect.top - band.top - 12 : band.bottom - rect.bottom - 12;
setSlash({ node, offset, query: "", x: Math.max(0, Math.min(rect.left - box.left - 8, box.width - 244)), y: above ? rect.top - box.top - 6 : rect.bottom - box.top + 6, above, max: Math.max(150, room) });
setSlashActive(0);
setBar(null);
}, []);
const slashResults = useMemo(() => {
const query = slash?.query.trim().toLowerCase() ?? "";
if (!query) return SLASH_ITEMS;
return SLASH_ITEMS.filter(item => `${item.label} ${item.keywords}`.toLowerCase().split(/\s+/).some(word => word.startsWith(query)) || item.label.toLowerCase().includes(query));
}, [slash?.query]);
const slashIndex = Math.min(slashActive, Math.max(0, slashResults.length - 1));
const slashOpen = !!slash && slashResults.length > 0;
const applyBlock = useCallback((type: BlockType, target?: HTMLElement | null) => {
const editor = editorRef.current;
const selection = window.getSelection();
if (!editor || !selection?.rangeCount) return;
const range = selection.getRangeAt(0);
const offsets = readOffsets(editor);
const block = target ?? blockAt(editor, range.startContainer);
if (!block) return;
if (type === "hr") {
const top = block.nodeName === "LI" ? block.closest("ul, ol")! : block;
const rule = document.createElement("hr");
if (!visibleText(block) && block.nodeName !== "LI") replaceNode(block, rule); else insertAfter(top, rule);
placeAtStart(paragraphAfter(rule));
return;
}
// Several selected blocks turn together; if every one already has the type, they all turn back into paragraphs.
const blocks = range.collapsed ? [block] : Array.from(editor.querySelectorAll<HTMLElement>("p, h1, h2, h3, li, blockquote, pre")).filter(candidate => range.intersectsNode(candidate) && !candidate.querySelector("p, li"));
const current = (candidate: HTMLElement) => candidate.nodeName === "LI" ? candidate.parentElement!.nodeName.toLowerCase() : candidate.nodeName.toLowerCase();
const allSame = blocks.every(candidate => current(candidate) === type);
blocks.forEach(candidate => {
if (allSame) convertBlock(candidate, type);
else if (current(candidate) !== type) convertBlock(candidate, type);
});
writeOffsets(editor, offsets);
if (type === "pre") {
const pre = blockAt(editor, window.getSelection()?.anchorNode ?? null);
if (pre && !pre.nextElementSibling) paragraphAfter(pre);
}
}, []);
const chooseSlash = (item: SlashItem | undefined) => {
const open = slashRef.current;
const editor = editorRef.current;
if (!item || !open || !editor) return;
playUiSound("select");
const selection = window.getSelection();
const caret = selection?.rangeCount && selection.getRangeAt(0).startContainer === open.node ? selection.getRangeAt(0).startOffset : open.offset + 1 + open.query.length;
setSlash(null);
run(() => {
open.node.deleteData(open.offset, Math.max(1, caret - open.offset));
const block = blockAt(editor, open.node);
placeCaret(open.node, open.offset);
if (block) { ensureFilled(block); applyBlock(item.type, block); }
});
setAnnounce(`${item.label} 블록을 추가했어요`);
};
/* Inline marks and links. */
const toggleMark = (mark: "bold" | "italic" | "strike" | "code") => { playUiSound(formats[mark] ? "toggle-off" : "toggle-on"); run(() => {
const editor = editorRef.current!;
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
if (mark !== "code") { document.execCommand("styleWithCSS", false, "false"); document.execCommand(mark === "strike" ? "strikeThrough" : mark); return; }
const startElement = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;
const code = startElement?.closest("code");
if (code && editor.contains(code) && !code.closest("pre")) {
const text = document.createTextNode(code.textContent ?? "");
replaceNode(code, text);
const next = document.createRange();
next.selectNodeContents(text);
selection.removeAllRanges();
selection.addRange(next);
return;
}
if (range.collapsed || blockAt(editor, range.startContainer) !== blockAt(editor, range.endContainer)) return;
const element = document.createElement("code");
element.textContent = range.toString();
range.deleteContents();
range.insertNode(element);
const next = document.createRange();
next.selectNodeContents(element);
selection.removeAllRanges();
selection.addRange(next);
}); };
const openLink = () => {
const editor = editorRef.current;
const selection = window.getSelection();
if (!editor || !selection?.rangeCount) return;
let range = selection.getRangeAt(0);
const startElement = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;
const existing = startElement?.closest("a");
if (existing && editor.contains(existing)) {
range = document.createRange();
range.selectNodeContents(existing);
selection.removeAllRanges();
selection.addRange(range);
}
if (range.collapsed) return;
savedRange.current = range.cloneRange();
setLinkDraft(existing?.getAttribute("href") ?? "");
setFace("link");
faceRef.current = "link";
sync();
requestAnimationFrame(() => linkInputRef.current?.focus({ preventScroll: true }));
};
const closeLink = (focusEditor = true) => {
setFace("format");
faceRef.current = "format";
const editor = editorRef.current, range = savedRange.current;
savedRange.current = null;
if (focusEditor && editor && range) {
editor.focus({ preventScroll: true });
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
sync();
};
const applyLink = (remove = false) => {
const range = savedRange.current;
const editor = editorRef.current;
const url = normalizeUrl(linkDraft);
if (!range || !editor) { closeLink(); return; }
playUiSound("click");
editor.focus({ preventScroll: true });
const selection = window.getSelection()!;
selection.removeAllRanges();
selection.addRange(range);
run(() => {
const startElement = isElement(range.startContainer) ? range.startContainer : range.startContainer.parentElement;
const existing = startElement?.closest("a");
if (remove || !url) {
if (existing && editor.contains(existing)) replaceNode(existing, ...Array.from(existing.childNodes));
else document.execCommand("unlink");
return;
}
if (existing && editor.contains(existing) && range.toString() === existing.textContent) { existing.setAttribute("href", url); return; }
if (blockAt(editor, range.startContainer) !== blockAt(editor, range.endContainer)) { document.execCommand("createLink", false, url); return; }
const contents = range.extractContents();
contents.querySelectorAll("a").forEach(anchor => replaceNode(anchor, ...Array.from(anchor.childNodes)));
const anchor = document.createElement("a");
anchor.setAttribute("href", url);
anchor.appendChild(contents);
range.insertNode(anchor);
const after = document.createRange();
after.setStartAfter(anchor);
after.collapse(true);
selection.removeAllRanges();
selection.addRange(after);
});
savedRange.current = null;
setFace("format");
faceRef.current = "format";
setBar(null);
setAnnounce(remove || !url ? "링크를 지웠어요" : "링크를 추가했어요");
};
/* Keyboard. Enter, Backspace at a block edge, and history go through beforeinput so mobile keyboards behave the same. */
const enterKey = useCallback((soft: boolean) => {
const editor = editorRef.current!;
const selection = window.getSelection();
if (!selection?.rangeCount) return;
let range = selection.getRangeAt(0);
if (!range.collapsed) { range.deleteContents(); range = selection.getRangeAt(0); }
const block = blockAt(editor, range.startContainer);
if (!block) return;
if (block.nodeName === "PRE") {
const full = block.textContent ?? "";
const caret = textBefore(block, range).length;
const head = full.slice(0, caret), tail = full.slice(caret);
if (!tail.replace(/\n$/, "") && head.endsWith("\n") && !soft) {
block.textContent = head.slice(0, -1) || "";
ensureFilled(block);
const paragraph = document.createElement("p");
paragraph.appendChild(document.createElement("br"));
insertAfter(block, paragraph);
placeAtStart(paragraph);
return;
}
block.textContent = head + "\n" + (tail || "\n");
placeCaret(block.firstChild!, caret + 1);
return;
}
if (soft) {
const br = document.createElement("br");
range.insertNode(br);
if (!br.nextSibling || (isText(br.nextSibling) && !br.nextSibling.data && !br.nextSibling.nextSibling)) insertAfter(br, document.createElement("br"));
const after = document.createRange();
after.setStartAfter(br);
after.collapse(true);
selection.removeAllRanges();
selection.addRange(after);
return;
}
if (block.nodeName === "LI" && !visibleText(block) && !block.querySelector("ul, ol")) { placeAtStart(outdent(block)); return; }
if (block.nodeName === "BLOCKQUOTE" && !visibleText(block)) { placeAtStart(convertBlock(block, "p")); return; }
const tail = document.createRange();
tail.setStart(range.startContainer, range.startOffset);
tail.setEnd(block, block.childNodes.length);
const atEnd = !tail.toString().replace(ZWSP, "") && !tail.cloneContents().querySelector("ul, ol");
const heading = /^H[1-3]$/.test(block.nodeName);
const fragment = tail.extractContents();
const next = document.createElement(heading && atEnd ? "p" : block.nodeName.toLowerCase());
if (!(heading && atEnd)) next.appendChild(fragment);
// Formatting open at the caret carries into the new line only when there is text to carry.
if (heading && atEnd) next.appendChild(document.createElement("br"));
insertAfter(block, next);
block.querySelectorAll(":scope > br:last-child").forEach(br => { if (visibleText(block)) br.remove(); });
ensureFilled(block);
ensureFilled(next);
if (!visibleText(next)) next.querySelectorAll("strong, em, s, code, a").forEach(element => { if (!visibleText(element)) element.remove(); });
ensureFilled(next);
placeAtStart(next);
}, []);
const backspaceAtStart = useCallback(() => {
const editor = editorRef.current!;
const selection = window.getSelection();
if (!selection?.rangeCount) return false;
const range = selection.getRangeAt(0);
const block = blockAt(editor, range.startContainer);
if (!block || !atBlockStart(block, range)) return false;
if (/^(H[1-3]|BLOCKQUOTE|PRE)$/.test(block.nodeName)) { placeAtStart(convertBlock(block, "p")); return true; }
if (block.nodeName === "LI") { placeAtStart(outdent(block)); return true; }
const previous = block.previousElementSibling;
if (previous?.nodeName === "HR") { previous.remove(); return true; }
if (!previous && !visibleText(block) && block.nextElementSibling) { const next = block.nextElementSibling as HTMLElement; block.remove(); placeAtStart(next); return true; }
return false;
}, []);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const onBeforeInput = (event: InputEvent) => {
if (readOnly) return;
switch (event.inputType) {
case "historyUndo": event.preventDefault(); undo(); return;
case "historyRedo": event.preventDefault(); redo(); return;
case "insertParagraph":
case "insertLineBreak":
event.preventDefault();
if (slashRef.current) return;
run(() => enterKey(event.inputType === "insertLineBreak"));
return;
case "deleteContentBackward": {
const selection = window.getSelection();
if (!selection?.rangeCount || !selection.getRangeAt(0).collapsed) return;
busy.current = true;
let handled = false;
try { handled = backspaceAtStart(); } finally { busy.current = false; }
if (handled) { event.preventDefault(); tidy(editor); record("command"); emit(); sync(); }
return;
}
case "insertText": {
// Typing into an emptied editor lands in a paragraph, never loose at the root.
const selection = window.getSelection();
if (!selection?.rangeCount || blockAt(editor, selection.anchorNode) || editor.querySelector("p, h1, h2, h3, li, pre, blockquote")) return;
event.preventDefault();
const paragraph = document.createElement("p");
const text = document.createTextNode(event.data ?? "");
paragraph.appendChild(text);
editor.replaceChildren(paragraph);
placeCaret(text, text.length);
record("type");
emit();
sync();
return;
}
case "insertFromDrop": event.preventDefault(); return;
case "formatUnderline": event.preventDefault(); return;
}
};
editor.addEventListener("beforeinput", onBeforeInput);
return () => editor.removeEventListener("beforeinput", onBeforeInput);
}, [backspaceAtStart, emit, enterKey, readOnly, record, redo, run, sync, undo]);
const onInput = (event: FormEvent<HTMLDivElement>) => {
if (busy.current) return;
const editor = editorRef.current!;
const native = event.nativeEvent as InputEvent;
if (native.isComposing) return;
busy.current = true;
let converted = false;
try {
tidy(editor);
if (native.inputType === "insertText") converted = shortcut(native.data);
} finally { busy.current = false; }
if (converted) {
tidy(editor);
record("command");
setAnnounce("서식을 바꿨어요");
} else record("type", native.data === " " || native.inputType !== lastInput.current);
lastInput.current = native.inputType;
emit();
if (!converted && native.inputType === "insertText") openSlashIfTyped(native.data);
sync();
};
const focusToolbar = () => rootRef.current?.querySelector<HTMLElement>(`[data-toolbar="${uid}"] button`)?.focus();
const onKeyDown = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (readOnly) return;
if (event.nativeEvent.isComposing || event.keyCode === 229) return;
const mod = event.metaKey || event.ctrlKey;
const key = event.key.toLowerCase();
if (slashOpen) {
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
event.preventDefault();
setSlashActive((slashIndex + (event.key === "ArrowDown" ? 1 : -1) + slashResults.length) % slashResults.length);
return;
}
if (event.key === "Enter" || event.key === "Tab") { event.preventDefault(); chooseSlash(slashResults[slashIndex]); return; }
if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); setSlash(null); return; }
}
if (mod && key === "z") { event.preventDefault(); if (event.shiftKey) redo(); else undo(); return; }
if (mod && key === "y") { event.preventDefault(); redo(); return; }
if (mod && !event.shiftKey && key === "b") { event.preventDefault(); toggleMark("bold"); return; }
if (mod && !event.shiftKey && key === "i") { event.preventDefault(); toggleMark("italic"); return; }
if (mod && event.shiftKey && key === "x") { event.preventDefault(); toggleMark("strike"); return; }
if (mod && key === "e") { event.preventDefault(); toggleMark("code"); return; }
if (mod && key === "k") { event.preventDefault(); openLink(); return; }
if (mod && key === "u") { event.preventDefault(); return; }
if (event.altKey && event.key === "F10" && bar) { event.preventDefault(); focusToolbar(); return; }
if (event.key === "Tab") {
const editor = editorRef.current!;
const selection = window.getSelection();
const block = selection?.rangeCount ? blockAt(editor, selection.anchorNode) : null;
if (block?.nodeName === "LI") {
event.preventDefault();
run(() => {
const offsets = readOffsets(editor);
if (event.shiftKey) outdent(block); else indent(block);
writeOffsets(editor, offsets);
});
} else if (block?.nodeName === "PRE" && !event.shiftKey) {
event.preventDefault();
run(() => {
const range = selection!.getRangeAt(0);
range.deleteContents();
const spaces = document.createTextNode(" ");
range.insertNode(spaces);
placeCaret(spaces, 2);
block.normalize();
});
}
}
};
const onPaste = (event: ReactClipboardEvent<HTMLDivElement>) => {
if (readOnly) return;
event.preventDefault();
const editor = editorRef.current!;
const html = event.clipboardData.getData("text/html");
const text = event.clipboardData.getData("text/plain");
run(() => {
const selection = window.getSelection();
if (!selection?.rangeCount) return;
const range = selection.getRangeAt(0);
range.deleteContents();
const block = blockAt(editor, range.startContainer);
const markdownish = /(^|\n)\s*(#{1,3}\s|[-*+]\s|\d+[.)]\s|>|```)|\*\*|`[^`]+`|\[[^\]]+\]\(/.test(text);
const source = html ? sanitizeHtml(html) : markdownish || text.includes("\n\n") ? markdownToHtml(text) : "";
const fragment = source ? parseHtml(source) : null;
const single = fragment && fragment.children.length === 1 && fragment.firstElementChild!.nodeName === "P";
// Plain text, code blocks, and list items take the text as it is; everything else keeps its structure.
if (!fragment || !block || block.nodeName === "PRE" || (block.nodeName === "LI" && !single)) {
const plain = block?.nodeName === "PRE" ? text : text.replace(/\s*\n\s*/g, " ");
const node = document.createTextNode(plain);
range.insertNode(node);
placeCaret(node, node.length);
return;
}
if (single) {
const nodes = Array.from(fragment.firstElementChild!.childNodes);
const last = nodes[nodes.length - 1];
const holder = document.createDocumentFragment();
nodes.forEach(node => holder.appendChild(node));
range.insertNode(holder);
if (last) { const after = document.createRange(); after.setStartAfter(last); after.collapse(true); selection.removeAllRanges(); selection.addRange(after); }
return;
}
const tail = document.createRange();
tail.setStart(range.startContainer, range.startOffset);
tail.setEnd(block, block.childNodes.length);
const rest = document.createElement(block.nodeName === "LI" ? "p" : block.nodeName.toLowerCase());
rest.appendChild(tail.extractContents());
const top = block.nodeName === "LI" ? block.closest("ul, ol")! : block;
const blocks = Array.from(fragment.childNodes);
let anchor: Element = top;
blocks.forEach(node => { insertAfter(anchor, node); if (isElement(node)) anchor = node; });
if (visibleText(rest) || rest.querySelector("br")) { insertAfter(anchor, rest); ensureFilled(rest); }
if (!visibleText(block) && block.nodeName === "P") block.remove();
const end = document.createRange();
end.selectNodeContents(anchor);
end.collapse(false);
selection.removeAllRanges();
selection.addRange(end);
});
};
/* Selection events. */
useEffect(() => {
let frame = 0;
const onChange = () => { cancelAnimationFrame(frame); frame = requestAnimationFrame(sync); };
const onUp = () => { if (!dragging.current) return; dragging.current = false; onChange(); };
document.addEventListener("selectionchange", onChange);
document.addEventListener("pointerup", onUp);
return () => { cancelAnimationFrame(frame); document.removeEventListener("selectionchange", onChange); document.removeEventListener("pointerup", onUp); };
}, [sync]);
useEffect(() => {
if (face !== "link") return;
const down = (event: PointerEvent) => { if (!rootRef.current?.querySelector(`[data-toolbar="${uid}"]`)?.contains(event.target as Node)) { savedRange.current = null; setFace("format"); faceRef.current = "format"; setBar(null); } };
document.addEventListener("pointerdown", down);
return () => document.removeEventListener("pointerdown", down);
}, [face, uid]);
useImperativeHandle(ref, () => ({
focus: () => editorRef.current?.focus(),
getHTML: () => editorRef.current ? cleanClone(editorRef.current).innerHTML : "",
getMarkdown: () => editorRef.current ? htmlToMarkdown(cleanClone(editorRef.current)) : "",
getText: () => editorRef.current ? plainOutput(cleanClone(editorRef.current)) : "",
setHTML: html => { load(html); record("command"); emit(); },
setMarkdown: markdown => { load(markdownToHtml(markdown)); record("command"); emit(); },
clear: () => { load("<p><br></p>"); record("command"); emit(); },
undo,
redo,
get element() { return editorRef.current; },
}), [emit, load, record, redo, undo]);
/* Toolbar geometry: one surface whose size springs between faces and whose position glides with the selection. */
const tx = useMotionValue(0), ty = useMotionValue(0), tw = useMotionValue(0), th = useMotionValue(0);
const shown = useRef(false);
/* Until a face has reported its size the surface has nothing to show, so it stays hidden rather than flashing a 1px dot. */
const toolbarVisibility = useTransform(tw, width => width > 0 ? "visible" : "hidden");
const targetSize = useRef({ width: 0, height: 0 });
const onFaceSize = useCallback((width: number, height: number) => {
targetSize.current = { width, height };
if (!shown.current || reduced || tw.get() === 0) { tw.jump(width); th.jump(height); return; }
animate(tw, width, MORPH);
animate(th, height, MORPH);
}, [reduced, th, tw]);
const toolbarOpen = !!bar && !readOnly;
// A fresh toolbar is placed, not glided, and stays "fresh" until it has painted once, so a selection that settles in the same
// frame cannot send it sliding in from its last spot or from the root's corner. Motion resets a remounted element's bound
// values to what they held at render (Strict Mode does this on every open in development), so the placement is applied
// again once mounting is done. Its size is left alone on close so the exit keeps its shape instead of collapsing.
const placedFrame = useRef(0);
const placement = useRef<{ x: number; y: number } | null>(null);
const place = useCallback(() => {
const at = placement.current;
if (!at) return;
const { width, height } = targetSize.current;
if (tx.get() !== at.x) tx.jump(at.x);
if (ty.get() !== at.y) ty.jump(at.y);
if (width && tw.get() !== width) { tw.jump(width); th.jump(height); }
}, [th, tw, tx, ty]);
useLayoutEffect(() => {
if (!bar) { shown.current = false; placement.current = null; cancelAnimationFrame(placedFrame.current); return; }
const root = rootRef.current;
const half = (targetSize.current.width || 280) / 2;
const x = root ? Math.max(half + 4, Math.min(bar.x, root.offsetWidth - half - 4)) : bar.x;
if (!shown.current || reduced) {
placement.current = { x, y: bar.y };
place();
cancelAnimationFrame(placedFrame.current);
placedFrame.current = requestAnimationFrame(() => { place(); shown.current = true; placement.current = null; });
return;
}
animate(tx, x, GLIDE);
animate(ty, bar.y, GLIDE);
}, [bar, place, reduced, tx, ty]);
useEffect(() => { if (bar) place(); }, [bar, place]);
useEffect(() => () => cancelAnimationFrame(placedFrame.current), []);
const direction = face === "link" ? 1 : -1;
/* Slash menu geometry: the highlight glides between rows and the panel springs to its rows. */
const listRef = useRef<HTMLUListElement>(null);
const hy = useMotionValue(0), hh = useMotionValue(0), panelHeight = useMotionValue<number | "auto">("auto");
const panelBody = useRef<HTMLDivElement>(null);
const slashPanel = useRef<HTMLDivElement>(null);
const slashMax = slash?.max ?? 320;
const panelSized = useRef(false);
const highlightPlaced = useRef(false);
useLayoutEffect(() => {
const row = listRef.current?.querySelector<HTMLElement>(`[data-index="${slashIndex}"]`);
if (!row) return;
if (reduced || !highlightPlaced.current || hh.get() === 0) { hy.jump(row.offsetTop); hh.jump(row.offsetHeight); highlightPlaced.current = true; return; }
animate(hy, row.offsetTop, GLIDE);
animate(hh, row.offsetHeight, GLIDE);
// A short panel scrolls to keep the active row in view.
const panel = slashPanel.current;
if (panel) {
const top = row.offsetTop + 6, bottom = top + row.offsetHeight;
if (top - 6 < panel.scrollTop) panel.scrollTop = top - 6;
else if (bottom + 6 > panel.scrollTop + panel.clientHeight) panel.scrollTop = bottom + 6 - panel.clientHeight;
}
}, [hh, hy, reduced, slashIndex, slashResults, slashOpen]);
useLayoutEffect(() => {
// The highlight keeps its row while the menu fades out; the next open places it fresh.
if (!slashOpen) { panelSized.current = false; highlightPlaced.current = false; return; }
const body = panelBody.current;
if (!body) return;
const fit = () => {
const target = Math.min(body.offsetHeight, slashMax);
if (!panelSized.current || reduced) { panelHeight.jump(target); panelSized.current = true; return; }
animate(panelHeight, target, GROW);
};
fit();
if (typeof ResizeObserver === "undefined") return;
const observer = new ResizeObserver(fit);
observer.observe(body);
return () => observer.disconnect();
}, [hh, panelHeight, reduced, slashMax, slashOpen]);
const onToolbarKey = (event: ReactKeyboardEvent<HTMLDivElement>) => {
if (face !== "format") return;
const buttons = Array.from(event.currentTarget.querySelectorAll<HTMLButtonElement>("button"));
const at = buttons.indexOf(document.activeElement as HTMLButtonElement);
if (event.key === "ArrowRight" || event.key === "ArrowLeft") {
event.preventDefault();
buttons[(at + (event.key === "ArrowRight" ? 1 : -1) + buttons.length) % buttons.length]?.focus();
} else if (event.key === "Escape") {
event.preventDefault();
editorRef.current?.focus();
sync();
}
};
const optionId = (index: number) => `${uid}-slash-${index}`;
const cssVars = { "--placeholder": JSON.stringify(placeholder), "--hint": JSON.stringify(blockHint) } as CSSProperties;
return <div ref={rootRef} className={[styles.root, className].filter(Boolean).join(" ")} style={style}>
<div
ref={editorRef}
className={styles.editor}
style={cssVars}
contentEditable={!readOnly}
suppressContentEditableWarning
role="textbox"
aria-multiline="true"
aria-label={ariaLabel}
aria-readonly={readOnly || undefined}
aria-controls={slashOpen ? `${uid}-slash` : undefined}
aria-activedescendant={slashOpen ? optionId(slashIndex) : undefined}
data-empty={empty || undefined}
spellCheck
onInput={onInput}
onKeyDown={onKeyDown}
onPaste={onPaste}
onDrop={event => event.preventDefault()}
onPointerDown={() => { dragging.current = true; }}
onCompositionEnd={() => { record("type", true); emit(); }}
/>
<span className={styles.srOnly} aria-live="polite">{announce}</span>
<AnimatePresence>
{toolbarOpen && <motion.div
key="toolbar"
data-toolbar={uid}
role="toolbar"
aria-label="서식"
className={styles.toolbar}
data-below={bar.below || undefined}
style={{ left: tx, top: ty, width: tw, height: th, visibility: toolbarVisibility, transformOrigin: bar.below ? "50% 0" : "50% 100%" }}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .94, y: bar.below ? -4 : 4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .96, transition: { duration: duration.exit * .8, ease: standard } }}
transition={reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } }}
onKeyDown={onToolbarKey}
>
<AnimatePresence initial={false} custom={direction}>
{face === "format" ? <Face key="format" direction={direction} reduced={reduced} onSize={onFaceSize}>
<div className={styles.row}>
<ToolButton label="굵게" pressed={formats.bold} shortcut="⌘B" onPress={() => toggleMark("bold")}><HugeiconsIcon icon={TextBoldIcon} {...icon} /></ToolButton>
<ToolButton label="기울임" pressed={formats.italic} shortcut="⌘I" onPress={() => toggleMark("italic")}><HugeiconsIcon icon={TextItalicIcon} {...icon} /></ToolButton>
<ToolButton label="취소선" pressed={formats.strike} shortcut="⌘⇧X" onPress={() => toggleMark("strike")}><HugeiconsIcon icon={TextStrikethroughIcon} {...icon} /></ToolButton>
<ToolButton label="인라인 코드" pressed={formats.code} shortcut="⌘E" onPress={() => toggleMark("code")}><HugeiconsIcon icon={CodeIcon} {...icon} /></ToolButton>
<ToolButton label={formats.link ? "링크 수정하기" : "링크"} pressed={!!formats.link} shortcut="⌘K" onPress={() => { playUiSound("click"); openLink(); }}><UiIcon iconRole="link" {...icon} /></ToolButton>
<span className={styles.blockTools}>
<span className={styles.divider} aria-hidden="true" />
<ToolButton label="제목 1" pressed={formats.block === "h1"} onPress={() => { playUiSound(formats.block === "h1" ? "toggle-off" : "toggle-on"); run(() => applyBlock("h1")); }}><HugeiconsIcon icon={Heading01Icon} {...icon} /></ToolButton>
<ToolButton label="제목 2" pressed={formats.block === "h2"} onPress={() => { playUiSound(formats.block === "h2" ? "toggle-off" : "toggle-on"); run(() => applyBlock("h2")); }}><HugeiconsIcon icon={Heading02Icon} {...icon} /></ToolButton>
<ToolButton label="인용" pressed={formats.block === "blockquote"} onPress={() => { playUiSound(formats.block === "blockquote" ? "toggle-off" : "toggle-on"); run(() => applyBlock("blockquote")); }}><HugeiconsIcon icon={LeftToRightBlockQuoteIcon} {...icon} /></ToolButton>
</span>
</div>
</Face> : <Face key="link" direction={direction} reduced={reduced} onSize={onFaceSize}>
<form className={styles.linkRow} onSubmit={event => { event.preventDefault(); applyLink(); }}>
<button type="button" className={styles.tool} aria-label="서식으로 돌아가기" onClick={() => { playUiSound("click"); closeLink(); }}><UiIcon iconRole="arrow-left" {...icon} /></button>
<input
ref={linkInputRef}
className={styles.linkInput}
value={linkDraft}
onChange={event => setLinkDraft(event.target.value)}
onKeyDown={event => { if (event.key === "Escape") { event.preventDefault(); event.stopPropagation(); closeLink(); } }}
placeholder="링크를 붙여넣거나 입력해 주세요"
aria-label="링크 주소"
inputMode="url"
autoComplete="off"
spellCheck={false}
/>
{formats.link && <button type="button" className={styles.tool} aria-label="링크 지우기" onClick={() => applyLink(true)}><HugeiconsIcon icon={Unlink01Icon} {...icon} /></button>}
<button type="submit" className={styles.apply} aria-label="링크 적용하기" data-ready={!!linkDraft.trim() || undefined}><UiIcon iconRole="check" size={16} strokeWidth={2} aria-hidden="true" /></button>
</form>
</Face>}
</AnimatePresence>
</motion.div>}
</AnimatePresence>
<AnimatePresence>
{slashOpen && slash && <motion.div
key={`${slash.offset}-${slash.y}`}
ref={slashPanel}
className={styles.slash}
data-above={slash.above || undefined}
style={{ left: slash.x, top: slash.y, height: panelHeight, maxHeight: slashMax, transformOrigin: slash.above ? "16px 100%" : "16px 0" }}
initial={reduced ? { opacity: 0 } : { opacity: 0, scale: .96, y: slash.above ? 4 : -4 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={reduced ? { opacity: 0 } : { opacity: 0, scale: .97, transition: { duration: duration.exit * .8, ease: standard } }}
transition={reduced ? { duration: duration.fast } : { ...spring.snappy, opacity: { duration: duration.fast, ease: enter } }}
onMouseDown={event => event.preventDefault()}
>
<div ref={panelBody} className={styles.slashBody}>
<ul ref={listRef} id={`${uid}-slash`} role="listbox" aria-label="블록" className={styles.slashList}>
<motion.li aria-hidden="true" className={styles.slashHighlight} style={{ y: hy, height: hh }} />
{slashResults.map((item, index) => <li key={item.type} id={optionId(index)} role="option" aria-selected={index === slashIndex} data-index={index} className={styles.slashOption}
onPointerMove={() => { if (index !== slashIndex) setSlashActive(index); }} onClick={() => chooseSlash(item)}>
<span className={styles.slashIcon}>{item.icon}</span>
<span className={styles.slashLabel}>{item.label}</span>
{item.hint && <kbd className={styles.slashHint}>{item.hint}</kbd>}
</li>)}
</ul>
</div>
</motion.div>}
</AnimatePresence>
</div>;
});
export default RichTextEditor;