@cp949/geul-react
v0.1.1
Published
Readme
@cp949/geul-react
@cp949/geul-core 위에 구현한 React 바인딩과 UI 컴포넌트다. EditorProvider/EditorContent로 에디터를 렌더링하고, FormattingToolbar/LinkToolbar/MediaToolbar/FilePanel/SlashMenu/EmojiPicker/StaticToolbar 등 보조 UI를 함께 제공한다. FormattingToolbar는 텍스트 선택 시에만 뜨는 반면 StaticToolbar는 선택과 무관하게 항상 렌더되는 옵트인 툴바다 — 위치(예: 상단 고정)는 강제하지 않으므로 className으로 소비 앱이 직접 CSS(position: sticky 등)를 붙인다.
최소 사용 예
import { EditorContent, EditorProvider } from "@cp949/geul-react";
import "@cp949/geul-react/styles.css";
import { createEmptyDocument, createRandomDocumentId } from "@cp949/geul-model";
// 인자는 블록 id를 만드는 함수다.
// createRandomDocumentId는 Chrome75 호환 UUID v4 생성기다.
const initialDocument = createEmptyDocument(createRandomDocumentId);
function Editor() {
return (
<EditorProvider initialDocument={initialDocument}>
<EditorContent />
</EditorProvider>
);
}기능 구성과 업로드
블록 타입 on/off
createEditor()/EditorProvider에 enabledBlockTypes: { mode: "allow" | "deny", types: Block["type"][] }를 넘기면 지정한 블록만 허용하거나 차단한다. 마운트 시점에 고정되고(런타임 토글 불가), SlashMenu·toolbar 등 UI가 비활성 블록 항목을 자동으로 숨긴다.
<EditorProvider
initialDocument={initialDocument}
enabledBlockTypes={{ mode: "deny", types: ["table"] }}
>
<EditorContent />
</EditorProvider>실제 연결 예는 apps/showcase의 src/examples/16-enabled-block-types를 참고한다.
이미지 업로드
EditorProvider의 uploadFile: (file, signal) => Promise<UploadResult> 콜백이 이미지·비디오·오디오·파일 블록의 업로드를 전부 처리한다 — 성공 시 { status: "success", url }, 실패 시 에러 코드, 취소 시 { status: "cancelled" }를 돌려준다. 실제 연결 예는 apps/showcase의 src/examples/07-media를 참고한다.
<EditorProvider
initialDocument={initialDocument}
uploadFile={async (file, signal) => {
const res = await fetch("/api/upload", { method: "POST", body: file, signal });
if (!res.ok) return { status: "error", code: String(res.status), message: res.statusText };
const { url } = await res.json();
return { status: "success", url };
}}
>
<EditorContent />
</EditorProvider>파일 업로드
이미지와 동일한 uploadFile 콜백을 쓴다 — 별도 콜백은 없다. FilePanel UI 자체는 apps/showcase의 src/examples/06-file-panel, 업로드까지 연결된 예는 07-media에 있다.
<EditorProvider initialDocument={initialDocument} uploadFile={uploadFile}>
<EditorContent />
<FilePanel />
</EditorProvider>코드 구문 강조 연결
geul은 highlight.js·Prism·Shiki 같은 구문 강조 라이브러리를 소유하지 않는다. packages/core가 중립적인 공개 seam 타입 SyntaxHighlighter만 정의하고, ProseMirror 연결(decoration 생성·캐시·비동기 재계산)은 내부적으로 prosemirror-highlight가 처리한다. EditorProvider에 syntaxHighlighter 옵션을 연결하지 않으면 모든 코드 블록은 조용히 plain text로 렌더된다 — 에러도 경고도 없다.
type SyntaxHighlightToken = {
/** source 문자열 안 시작 오프셋(0-indexed, code unit 기준). */
from: number;
/** source 문자열 안 끝 오프셋(exclusive). */
to: number;
/** 적용할 CSS class. 색상 자체는 geul이 소유하지 않는다 — 이 class를 정의하는 스타일시트는 소비자(또는 소비자가 고른 하이라이터의 테마)가 공급한다. */
className?: string;
};
type SyntaxHighlighter = (input: {
source: string;
language: string | undefined;
}) => readonly SyntaxHighlightToken[] | Promise<readonly SyntaxHighlightToken[]>;syntaxHighlighter는 initialDocument와 같은 마운트 시점 옵션이다 — 런타임에 껐다 켰다 할 수 없다. 아래는 lowlight(highlight.js 어댑터)로 만든 최소 실행 가능 예제다 — lowlight는 hast(hypertext AST) 트리를 돌려줄 뿐 SyntaxHighlighter 시그니처를 모르므로, 트리를 순회해 source 오프셋을 직접 계산하는 얇은 어댑터가 필요하다.
import { EditorContent, EditorProvider } from "@cp949/geul-react";
import { createEmptyDocument, createRandomDocumentId } from "@cp949/geul-model";
import "highlight.js/styles/github.css";
import { common, createLowlight } from "lowlight";
const lowlight = createLowlight(common);
type HastNode = ReturnType<typeof lowlight.highlight>["children"][number];
type Token = { from: number; to: number; className?: string };
// hast 트리를 source 오프셋 기준 token 목록으로 평탄화한다.
function flattenHastToTokens(
nodes: readonly HastNode[],
offset: number,
tokens: Token[],
): number {
let cursor = offset;
for (const node of nodes) {
if (node.type === "text") {
cursor += node.value.length;
continue;
}
if (node.type === "element") {
const from = cursor;
cursor = flattenHastToTokens(node.children, cursor, tokens);
const classNameProp = node.properties?.className;
const className = Array.isArray(classNameProp)
? classNameProp.join(" ")
: typeof classNameProp === "string"
? classNameProp
: undefined;
tokens.push({ from, to: cursor, ...(className && { className }) });
}
}
return cursor;
}
// 미지원/빈 language는 geul이 관여하지 않는다 — 빈 배열을 돌려주면
// 그 블록은 plain text로 남는다.
function lowlightSyntaxHighlighter({
source,
language,
}: {
source: string;
language: string | undefined;
}) {
if (language === undefined || !lowlight.registered(language)) return [];
const tree = lowlight.highlight(language, source);
const tokens: Token[] = [];
flattenHastToTokens(tree.children, 0, tokens);
return tokens;
}
// 인자는 블록 id를 만드는 함수다.
// createRandomDocumentId는 Chrome75 호환 UUID v4 생성기다.
const initialDocument = createEmptyDocument(createRandomDocumentId);
function Editor() {
return (
<EditorProvider
initialDocument={initialDocument}
syntaxHighlighter={lowlightSyntaxHighlighter}
>
<EditorContent />
</EditorProvider>
);
}EditorProvider는 언어 선택 콤보박스의 후보 목록을 바꾸는 codeBlockLanguages 옵션도 받는다(주지 않으면 javascript·typescript·html·css·json·bash·python·java·kotlin·sql·markdown 11개 기본값). 이 옵션은 initialDocument와 달리 매 렌더 반영되는 reactive 옵션이고, 콤보박스가 제안하는 후보만 바꿀 뿐 자유 입력 자체를 막지는 않는다.
<EditorProvider
initialDocument={initialDocument}
syntaxHighlighter={lowlightSyntaxHighlighter}
codeBlockLanguages={[
{ id: "javascript", label: "JavaScript", aliases: ["js"] },
{ id: "python", label: "Python", aliases: ["py"] },
]}
>
<EditorContent />
</EditorProvider>highlight.js/lowlight 외 나머지 4개 라이브러리(Prism/refractor, Shiki, CodeMirror/lezer, sugar-high)로 만든 동작 예제는 apps/showcase의 src/examples/10-syntax-highlighting-lowlight~14-syntax-highlighting-sugar-high 5개 폴더가 각각 자기완결적으로 담고 있다 — 라이브러리마다 hast 트리(lowlight·refractor), 콜백 기반 flat 구간(lezer), 오프셋 없는 줄→토큰 트리(sugar-high), 이미 오프셋을 가진 토큰(Shiki)처럼 출력 모양이 달라 어댑터 구현이 서로 다르다.
알려진 제약
EditorProvider/EditorContent는 서버 렌더 환경에서null을 렌더하고, 실제 편집기 생성(createEditor(),@cp949/geul-core)은useEffect안에서만 호출한다.createEditor()를 이 경로 없이 직접 호출하는 저수준 사용은 서버 환경에서도 크래시하지 않지만(EXT-013), 반환된 controller의 문서는 로드 시점 정규화가 실제 client mount 시점까지 지연된 상태일 수 있다.StaticToolbar는 아직 안정성이 부족하다. 보완 예정이다.
