@lumir-company/editor
v0.16.0
Published
LumirEditor — vanilla (HTML/JS/CSS) rich text editor, BlockNote-JSON round-trip compatible.
Readme
@lumir-company/editor
의존성 0(vanilla HTML/JS/CSS) 리치 텍스트 에디터 — BlockNote-JSON 라운드트립 호환.
구 BlockNote 기반
@lumir-company/[email protected]의 드롭인 대체입니다. 동일한 API 표면(LumirEditor컴포넌트·props·타입·s3Upload계약)을 유지하되, 코어에서 React / BlockNote / ProseMirror 런타임을 완전히 제거했습니다. 코어는 react-free라 서버·명령형 환경에서도 사용할 수 있고, React 컴포넌트는 선택적 래퍼로 제공됩니다.
목차
- 특징
- 설치
- 빠른 시작
- Exports (서브패스)
- 이미지 업로드
- 동영상·오디오·파일 업로드
- 업로드 진행률
- 이미지·비디오 삭제
- 테이블
- 고정 레이아웃 문서 (lockMode)
- 데이터 자리 칩 (chip)
- HTML 양식 템플릿
- 호스트 연동 API
- 다단 컬럼 (2·3단)
- 여러 줄 선택
- 글자 크기
- 링크
- Placeholder
- 테마
- Props API
- react-free 코어 API
- 유틸리티 API
- 스타일링
- 트러블슈팅
- 0.12.x → 0.13.0 마이그레이션
- 0.4.x → 0.5.0 마이그레이션
- 변경 이력
- 라이선스
특징
| 특징 | 설명 |
| --- | --- |
| 의존성 0 | 코어에 React/BlockNote/ProseMirror 런타임 없음(vanilla HTML/JS/CSS). React는 선택적 peer |
| 드롭인 호환 | 구 @lumir-company/[email protected]와 동일한 컴포넌트·props·타입·저장 JSON |
| react-free 코어 | mountLumirEditor() 명령형 API로 서버 컴포넌트·비-React 환경에서도 사용 가능 |
| 이미지 업로드 | 드래그앤드롭·붙여넣기·슬래시 메뉴, S3 presigned URL 내장, 파일명 커스터마이징, 로딩 표시 |
| 동영상/오디오 | allowVideoUpload·allowAudioUpload opt-in. 일반 파일 업로드는 차단 |
| 테이블 | Notion 스타일 grip 핸들, 셀 배경/글자색·정렬, 병합/분할, 행 높이·열 너비 리사이즈, 표 전체 종횡비 스케일, 에디터 폭 자동 맞춤, Shift·Ctrl 클릭 다중 선택, 잡은 칸 Delete 로 일괄 비우기, Excel/Word 붙여넣기 |
| 고정 레이아웃 문서 | lockMode design·fill·view. 지정한 칸(표 셀·문단)만 열고 나머지는 잠금, 허용한 표만 행 추가, fill에서 체크박스 토글 |
| 데이터 자리 칩 | 양식 설계용 인라인 표식(보안서약 boolean). 타입별 색 + 받은 vtype 그대로. 값을 담지 않고 자리만 보여준다 — 필드 목록·타입 어휘는 호스트 소관 |
| HTML 양식 | 호스트 카탈로그 기반 htmlTemplate, 편집 슬롯, Shadow DOM 격리와 정화 검사 |
| 2·3단 컬럼 | 노션식 다단 컬럼 레이아웃(MIT 자체 구현), 슬래시로 2·3단 삽입, 블록 DnD로 2단 생성, 블록별/전역 구분선, 마지막 블록에서 Enter 두 번으로 컬럼 탈출 |
| 표 행 높이 | 표마다 행 높이를 단계로 줄입니다(보통·좁게·촘촘히). 표 툴바에서 사용자가 고르고 저장 JSON 에 남습니다 |
| 셀 테두리 | 표 칸의 테두리를 굵기·선종·색으로 지정합니다(모든·바깥·안쪽·위·아래·왼·오·없음). 안 쓰면 렌더가 한 픽셀도 안 바뀝니다 |
| 글자 크기 | 인라인 글자 크기(프리셋 + 1px 스테퍼, 8~96px), 구버전 안전 직렬화 |
| 계층 들여쓰기 | Tab/Shift+Tab 으로 블록 종류를 가리지 않고 부모자식 중첩(노션과 같은 키). 캐럿이 줄 어디에 있든 같습니다(코드블록만 공백 2칸). 부모는 직전 형제이고 한 번에 한 단입니다. 내어쓰기는 뒤 형제를 자식으로 데려가 줄 순서를 지킵니다. 목록은 10단 상한(표 셀 안 인라인 마커는 3단), 번호는 목록마다 1부터 |
| 공백 들여쓰기 | Ctrl+]/Ctrl+[ 로 종속관계 없이 첫 줄만 들여쓰기(구글 독스와 같은 키). 페이지 첫 줄처럼 계층이 불가능한 자리에서도 됩니다. 서식 툴바에 버튼 넷(계층 2 · 공백 2) |
| 인라인 체크박스 | 표 셀·문단 어디든 한 블록에 여러 개(☐ 대 ☐ 중 ☐ 소), []+스페이스·슬래시로 삽입, Enter로 다음 줄 이어받기, 드래그로 영역선택·복사, 글자 크기 입력으로 상자 크기 조절 — 줄당 하나인 「할 일」 블록과 별개 |
| 여러 줄 선택·편집 | 블록 경계를 넘는 드래그로 여러 줄 선택 → 서식 일괄 적용, 선택 영역 삭제·타이핑·붙여넣기·부분 복사(무손실), Shift+클릭·Ctrl/Cmd+A 전체선택, 단일 Undo. 들여쓴 블록도 범위 안 |
| 링크 | URL 붙여넣기 → 인라인 링크, Notion식 링크 툴바(hover 툴팁 → 편집 popup) |
| 직렬화 | 블록 JSON ↔ HTML ↔ Markdown 상호 변환 유틸 공개 export |
| TypeScript | 전 표면 .d.ts 제공 |
| 테마 | 라이트/다크 + 커스텀 테마 객체 |
| 디자인 토큰 | 색·라운드·그림자·글꼴이 :root 의 --lumir-* 변수 — 호스트가 덮으면 편집기 전체가 따라감 |
지원 파일 형식
| 종류 | 형식 | 기본 용량 한도 |
| --- | --- | --- |
| 이미지 | PNG · JPEG/JPG · GIF · WebP · BMP (SVG는 XSS 방지로 제외) | 10MB |
| 동영상 | MP4 · WebM · OGG · MOV | 100MB |
| 오디오 | allowAudioUpload 활성화 시 | maxAudioFileSize 로 설정 |
일반 파일(문서·압축 등) 업로드는 차단됩니다 —
allowFileUpload는 아무 동작도 하지 않습니다(0.13.0@deprecated).
설치
npm i @lumir-company/editor
# 또는
yarn add @lumir-company/editor
# 또는
pnpm add @lumir-company/editorPeer dependencies (선택):
react≥ 18.0.0react-dom≥ 18.0.0
React/
react-dom은 React 컴포넌트(LumirEditor)나 드롭인 루트를 사용할 때만 필요합니다../core서브패스(vanilla)만 사용한다면 React 없이 동작합니다.
빠른 시작
중요:
@lumir-company/editor/style.css를 반드시 임포트하세요. 임포트하지 않으면 에디터가 정상적으로 렌더링되지 않습니다.
1. React 드롭인
import LumirEditor from "@lumir-company/editor"; // default export
// 또는: import { LumirEditor } from "@lumir-company/editor";
import "@lumir-company/editor/style.css";
export default function App() {
return (
<div style={{ height: 500 }}>
<LumirEditor
initialContent={blocks}
editable
s3Upload={{ apiEndpoint: "/api/s3/presigned", env: "production", path: "cms/wiki", appendUUID: true }}
onContentChange={(blocks) => save(JSON.stringify(blocks))}
onImageDelete={(url) => deleteFromS3(url)}
/>
</div>
);
}컨테이너에 높이를 지정해야 에디터가 보입니다.
2. Next.js
브라우저 전용 API를 사용하므로 SSR을 비활성화합니다.
"use client";
import dynamic from "next/dynamic";
import "@lumir-company/editor/style.css";
const LumirEditor = dynamic(
() => import("@lumir-company/editor").then((m) => ({ default: m.LumirEditor })),
{ ssr: false },
);
export default function EditorPage() {
return (
<div style={{ height: 500 }}>
<LumirEditor />
</div>
);
}3. react-free 코어 (vanilla)
React 없이 임의의 DOM에 마운트하는 명령형 API입니다. 서버 컴포넌트·순수 JS·다른 프레임워크에서 사용할 수 있습니다.
import { mountLumirEditor } from "@lumir-company/editor/core";
import "@lumir-company/editor/style.css";
const editor = mountLumirEditor(document.getElementById("host"), {
initialContent: blocks,
s3Upload: { apiEndpoint: "/api/s3/presigned", env: "production", path: "docs" },
onContentChange: (blocks) => save(blocks),
});
editor.getDocument(); // 현재 블록 JSON
editor.getHTML(); // HTML 문자열
editor.getMarkdown(); // Markdown 문자열
editor.destroy(); // 정리자세한 인스턴스 메서드는 react-free 코어 API를 참고하세요.
Exports (서브패스)
| 서브패스 | 내용 | React 필요 |
| --- | --- | --- |
| . | React LumirEditor(default + named) + 코어/유틸 전체 ("use client") | ✅ |
| ./react | .과 동일 표면(하위호환 별칭) | ✅ |
| ./core | react-free 코어(mountLumirEditor·직렬화·유틸) — 서버 컴포넌트 안전 | ❌ |
| ./style.css | 단일 번들 CSS | — |
각 서브패스는 types(.d.ts) / import(ESM) / require(CJS) 조건을 모두 제공합니다.
참고: 구버전에 있던
@lumir-company/editor/api/link-preview서브패스는 제거되었습니다. 마이그레이션을 참고하세요.
이미지 업로드
이미지는 붙여넣기 · 드래그 앤 드롭 · 슬래시 메뉴(/ → Image) · 사이드/플로팅 메뉴로 삽입됩니다. 업로드 방식은 아래 우선순위로 결정됩니다.
uploadFileprop이 있으면 → 해당 함수로 업로드- 없고
s3Upload가 있으면 → S3 presigned URL 업로드 - 둘 다 없으면 → 파일 삽입 시 업로드 실패
S3 업로드 (권장)
<LumirEditor
s3Upload={{
apiEndpoint: "/api/s3/presigned",
env: "production",
path: "blog/images",
}}
/>S3 저장 경로: {env}/{path}/{filename} — 예: production/blog/images/my-photo.png
API 엔드포인트 계약
클라이언트는 GET {apiEndpoint}?key={파일키}&contentType={MIME} 형태로 요청하고, 서버는 다음 JSON을 반환해야 합니다.
{
"presignedUrl": "https://s3.amazonaws.com/bucket/upload-url",
"publicUrl": "https://cdn.example.com/production/blog/images/my-photo.png"
}Presigned URL API 예시 (Next.js App Router)
app/api/s3/presigned/route.ts:
import { NextRequest, NextResponse } from "next/server";
import { S3Client, PutObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
const s3 = new S3Client({
region: process.env.AWS_REGION!,
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
// ⚠️ 필수: AWS SDK v3(≥3.729)는 기본 체크섬(WHEN_SUPPORTED)으로 presigned PUT 서명에
// x-amz-checksum-crc32 를 넣는데, 브라우저 PUT은 Content-Type만 전송 → 불일치로 S3 403.
// 아래처럼 체크섬을 "요구될 때만" 계산하도록 낮춰야 브라우저 직접 업로드가 통과됩니다.
requestChecksumCalculation: "WHEN_REQUIRED",
responseChecksumValidation: "WHEN_REQUIRED",
});
export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const key = searchParams.get("key"); // 업로드할 파일 키 ({env}/{path}/{filename})
const contentType = searchParams.get("contentType"); // MIME (선택, 없으면 application/octet-stream)
if (!key) return NextResponse.json({ error: "key is required" }, { status: 400 });
const command = new PutObjectCommand({
Bucket: process.env.AWS_S3_BUCKET!,
Key: key,
ContentType: contentType || "application/octet-stream",
});
const presignedUrl = await getSignedUrl(s3, command, { expiresIn: 60 }); // 유효 60초
const publicUrl = `https://${process.env.AWS_S3_BUCKET}.s3.${process.env.AWS_REGION}.amazonaws.com/${key}`;
return NextResponse.json({ presignedUrl, publicUrl, key });
}필요 환경 변수: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY, AWS_S3_BUCKET.
주의:
requestChecksumCalculation/responseChecksumValidation를"WHEN_REQUIRED"로 두지 않으면 최신 AWS SDK v3에서 브라우저 업로드가 403으로 실패합니다. 이 레포의 동작 예시는lumir-editor-test/src/app/api/s3/presigned/route.ts에 있습니다.
Express / Remix / SvelteKit 등도 동일하게
key·contentType을 받아{ presignedUrl, publicUrl }을 반환하는 GET 라우트를 만들면 됩니다.apiEndpoint만 해당 서버 주소로 맞추세요.
파일명 커스터마이징
여러 파일을 동시에 올릴 때 이름 충돌을 방지합니다. 기본적으로 확장자는 자동으로 붙습니다(preserveExtension: false로 끌 수 있음).
<LumirEditor
s3Upload={{
apiEndpoint: "/api/s3/presigned",
env: "production",
path: "uploads",
// 확장자를 제거한 이름(nameWithoutExt)이 전달됩니다
fileNameTransform: (nameWithoutExt, file) => `user123_${nameWithoutExt}`,
appendUUID: true, // 변환 후 UUID를 확장자 앞에 추가
// preserveExtension: false // 확장자를 붙이지 않음(서버에서 WebP 변환 등)
}}
/>결과: photo.png → user123_photo_550e8400-…-446655440000.png
커스텀 업로더
<LumirEditor
uploadFile={async (file) => {
const fd = new FormData();
fd.append("file", file);
const res = await fetch("/api/upload", { method: "POST", body: fd });
const { url } = await res.json();
return url; // 공개 URL 문자열 반환
}}
/>헬퍼: createS3Uploader
import { createS3Uploader } from "@lumir-company/editor";
const s3Uploader = createS3Uploader({
apiEndpoint: "/api/s3/presigned",
env: "production",
path: "images",
appendUUID: true,
});
<LumirEditor uploadFile={s3Uploader} />;
// 또는 독립 사용: const url = await s3Uploader(file);동영상·오디오·파일 업로드
동영상은 allowVideoUpload={true}일 때만 활성화됩니다. 업로드 설정(s3Upload/uploadFile)은 이미지와 동일하게 공유됩니다.
<LumirEditor
allowVideoUpload
maxVideoFileSize={200 * 1024 * 1024} // 200MB (기본 100MB)
s3Upload={{ apiEndpoint: "/api/s3/presigned", env: "production", path: "videos", appendUUID: true }}
/>- 삽입 경로: 붙여넣기, 드래그 앤 드롭, 슬래시 메뉴("Video"), 플로팅/사이드 메뉴
- 지원 URL: 비디오 블록의
url은 직접 재생 가능한 파일 URL(예:.mp4,.webm,.ogg)만 지원합니다. YouTube/Vimeo 등 스트리밍 페이지 URL은<video>로 재생되지 않습니다. - 오디오:
allowAudioUpload로 opt-in, 용량은maxAudioFileSize로 조절합니다. - 일반 파일 업로드는 차단됩니다 —
allowFileUpload는 아무 동작도 하지 않습니다(0.13.0@deprecated). 저장 문서에 이미 있는 파일 블록은 확장자 칩(PDF·XLS·ZIP)과props.size크기로 그려집니다.
이미지·동영상 경로 분리
fileNameTransform의 두 번째 인자 file로 종류를 구분해 prefix를 나눌 수 있습니다.
fileNameTransform: (nameWithoutExt, file) => {
const isVideo = file.type.startsWith("video/");
return `${isVideo ? "videos" : "images"}/${nameWithoutExt}`;
}
// 결과: production/uploads/videos/clip_xxx.mp4, production/uploads/images/photo_xxx.png저장 데이터 구조
{ "type": "image", "props": { "url": "https://cdn/…/photo.png", "caption": "", "previewWidth": 512, "textAlignment": "center" }, "content": [], "children": [] }
{ "type": "video", "props": { "url": "https://cdn/…/clip.mp4" }, "content": [], "children": [] }이미지 편집 (리사이즈 · 정렬)
이미지에 마우스를 올리면 손잡이 다섯이 뜹니다.
| 손잡이 | 바뀌는 값 | 종횡비 |
|---|---|---|
| 좌 · 우 | previewWidth | 높이는 비율 자동 |
| 하단 | previewHeight | 자유 — 세로로만 늘어납니다(찌그러짐 허용) |
| 좌하 · 우하 모서리 | 둘 다 | 고정 — 잡은 순간의 비를 지킵니다 |
previewHeight는 세로·대각 손잡이를 쓴 이미지에만 생기고, 없으면 종전처럼 비율이 자동입니다(기존 문서의 렌더는 바뀌지 않습니다). 이미지 안쪽 우상단에 뜨는 오버레이 툴바로 좌·가운데·우 정렬(textAlignment)·삭제를 할 수 있습니다. 세 값 모두 저장 데이터에 반영되어 라운드트립·HTML 내보내기(<img width height>)·붙여넣기에서 유지됩니다(읽기전용 모드에선 편집 UI 숨김).
빈 이미지·비디오 블록은 드롭존입니다 — 클릭하면 파일 선택창이 열리고, 그 자리에 파일을 놓으면 그 블록이 채워집니다(종전에는 옆에 새 블록이 생겼습니다). 이미지 로드에 실패하면 안내와 「다시 시도」가 뜹니다.
업로드 진행률
S3 업로드 시 s3Upload.onProgress 콜백으로 0~100% 진행률을 받을 수 있습니다. 에디터는 이 값을 툴바에 n%로 자동 표시합니다. 대용량 파일에서 브라우저가 progress 이벤트를 드물게 보내는 경우를 대비해 중간 진행률을 보간하여 부드럽게 갱신합니다.
<LumirEditor
allowVideoUpload
s3Upload={{
apiEndpoint: "/api/s3/presigned",
env: "production",
path: "videos",
uploadTimeoutMs: 180000, // PUT 타임아웃(ms). 기본 120000
maxRetries: 2, // PUT 실패 재시도. 기본 2(최대 3회 시도)
onProgress: (percent) => console.log(`${percent}%`),
}}
/>onProgress는 S3 PUT 요청 시에만 호출됩니다(presigned URL 요청 단계 제외).- 업로드 시작 직후
onProgress(0), 완료 시onProgress(100)이 보장됩니다.
이미지·비디오 삭제
에디터에서 이미지 또는 비디오 블록이 삭제되면 onImageDelete(url)이 호출됩니다(둘 다 동일 콜백).
<LumirEditor
s3Upload={{ /* … */ }}
onImageDelete={(url) => {
// S3 등 외부 스토리지 삭제
}}
/>권장: 지연 삭제 (Undo/Redo 대응)
Undo로 복원 가능하도록 실제 삭제를 지연시키는 패턴을 권장합니다.
"use client";
import { useRef, useCallback } from "react";
function Editor() {
const pending = useRef(new Map<string, ReturnType<typeof setTimeout>>());
const handleImageDelete = useCallback((url: string) => {
if (pending.current.has(url)) return;
const t = setTimeout(async () => {
pending.current.delete(url);
await fetch(`/api/s3/delete?url=${encodeURIComponent(url)}`, { method: "DELETE" });
}, 5 * 60 * 1000); // 5분 후 삭제
pending.current.set(url, t);
}, []);
return <LumirEditor s3Upload={{ /* … */ }} onImageDelete={handleImageDelete} />;
}| 항목 | 권장 | | --- | --- | | Undo/Redo | 지연 삭제(5~10분)로 복원 가능하게 구현 | | 권한 검증 | 프로덕션에서는 인증/인가 필수 | | 참조 카운트 | 같은 URL을 여러 문서에서 쓰는지 확인 |
테이블
슬래시 메뉴(/ → Table)나 Excel/Word 셀 붙여넣기로 표를 만듭니다.
표 툴바
셀에 캐럿을 두면 표 위에 툴바가 뜹니다. 같은 카테고리는 드롭다운 하나로 묶여 있습니다.
| 버튼 | 안에 든 것 | | --- | --- | | 가로 정렬 ▾ | 셀 왼쪽·가운데·오른쪽 정렬 | | 세로 정렬 ▾ | 위쪽·중간·아래쪽 맞춤 | | 삽입 ▾ | 위에 행 · 아래에 행 · 왼쪽에 열 · 오른쪽에 열 | | 삭제 ▾ | 행 삭제 · 열 삭제 | | 머리 ▾ | 머리행 · 머리열 | | 표 정렬 ▾ | 표 왼쪽 · 가운데 · 오른쪽 정렬 |
- 앵커 버튼은 아이콘만 있습니다. 이름은 hover 툴팁에 나오고, 메뉴를 열면 항목마다 한글 문장이 붙습니다.
- 두 정렬 앵커의 아이콘은 현재 값을 그대로 보여 줍니다 — 메뉴를 열지 않아도 지금 어느 정렬인지 알 수 있습니다.
- 메뉴는 한 번에 하나만 열립니다.
Esc로 닫고,↑``↓로 항목을 옮기고Enter로 실행합니다. - 접히지 않은 것: 글꼴·글자 크기·굵게/기울임/밑줄/취소선·텍스트 배경색(맨 왼쪽) · 셀 배경색·글자색 · 행/열 전체 선택 · 셀 안 체크박스·번호 목록·글머리 목록 · 표 삭제(맨 끝). 자주 누르는 것과 잘못 눌리면 안 되는 것은 밖에 둡니다.
- 셀 병합/분할은 상황에 맞을 때만 나타납니다(병합=셀 2개 이상 선택, 분할=병합 셀에 캐럿).
- 비활성 버튼은 왜 안 되는지를 툴팁으로 알립니다(예: 「열을 더 넣을 폭이 없습니다」).
표 칸 안 이미지
표 칸에 이미지를 붙여넣거나 떨어뜨리면 그 칸에 들어갑니다(예전에는 표 아래에 별도 블록으로
생겼습니다). 스크린샷·이미지 파일은 uploadFile/s3Upload 로 올라간 뒤 서버 URL 로 심기고,
웹에서 복사한 그림(<img src="https://…">)은 업로드 없이 그대로 들어옵니다. data:·blob:
그림은 문서에 남기지 않습니다(업로드 경로를 거치거나 버려집니다).
저장 모델은 인라인 노드입니다:
{ "type": "inlineImage", "url": "https://cdn/…/stamp.png", "width": 120, "height": 80,
"displayWidth": 160, "displayHeight": 90, "alt": "" }- 크기는 저장=원본, 화면=맞춤입니다.
width/height는 넣을 때의 원본 크기이고, 칸보다 크면 CSS 가 칸 폭까지 줄입니다(비율 유지). 열 너비를 바꾸면 그림이 함께 줄고 늘지만 저장값은 변하지 않습니다 — 좁은 열에서 저장한 문서를 넓은 화면에서 열어도 그림이 작아진 채 굳지 않습니다. - 기본 표시 상한은 240px입니다(
--lumir-inline-img-max-w). 도장·서명·검사 사진을 기준으로 잡은 값이고 호스트가 CSS 변수로 바꿀 수 있습니다. 이 상한이 길이(px)여야 하는 이유가 있습니다 — 퍼센트로 두면 표 너비가auto일 때 크로미움이 내용으로 표 폭을 먼저 정하면서 그 퍼센트를 무시해, 큰 그림 하나가 표를 편집영역 밖으로 밀어냅니다. - 그림에 마우스를 올리면 손잡이 넷이 뜹니다 — 좌·우(폭) · 하단(높이, 찌그러짐 허용) ·
우하단 모서리(비율 고정). 손으로 끈 크기는
displayWidth/displayHeight에 따로 담깁니다. 원본(width/height)을 덮지 않으므로 「저장=원본」계약이 그대로 유지됩니다. 드래그는 칸 폭을 상한으로 삼습니다(넘기면 표가 밀려납니다). - 세로로 긴 그림은 행 높이를 밀어냅니다(행 높이는 최소값이라 내용에 맞춰 자랍니다).
- 엑셀처럼 표와 비트맵이 함께 담긴 클립보드는 표가 이깁니다 — 표가 그림 한 장이 되지 않습니다.
- 인라인 이미지는 체크박스·목록 마커와 같은 인라인 원자입니다. BlockNote 표준 인라인 밖이라 구버전 소비자는 이 노드를 읽지 못합니다(기존 원자 3종과 같은 트레이드오프).
Notion 스타일 grip 핸들
셀에 포커스하면 주변에 grip 핸들이 표시됩니다.
| 위치 | 동작 | | --- | --- | | 상단 grip | 클릭 → 열 메뉴(삭제 / 좌·우 열 추가 / 색) · 드래그 → 열 이동 | | 좌측 grip | 클릭 → 행 메뉴(삭제 / 위·아래 행 추가 / 색) · 드래그 → 행 이동 | | 우측 grip | hover → 셀 메뉴(셀 배경색 등) |
- 행/열 메뉴가 열리면 해당 행/열 전체가 하이라이트됩니다.
- 표 우측/하단 가장자리 hover 시 행/열 추가 버튼이 나타납니다.
셀 색·정렬
- 단일 셀: 우측 grip 또는 행/열 메뉴의 "색"에서 배경색/글자색 적용
- 다중 셀: 드래그 · Shift+클릭(앵커에서 범위) · Ctrl/Cmd+클릭(떨어진 칸)으로 고른 뒤 표 툴바에서 일괄 적용
- 표 전체 선택: 셀 포커스 상태에서
Ctrl/Cmd + A→ 표의 모든 셀 선택(다시 누르면 문서 전체로 확장) - 병합 때문에 끌려온 칸은 점선으로 갈라 보여 줍니다. 떨어진 칸을 고르면 사각형을 전제하는 동작(병합 · 행/열 삭제 · 행/열 삽입 · 행/열 전체 선택)이 회색이 되고 이유를 툴팁으로 알립니다.
- 배경색 14색(진한 단계 4색 포함) · 글자색 10색.
셀 테두리 (굵기 · 선종 · 색)
표 툴바의 테두리를 열어 펜(색 · 굵기 · 선종)을 고르고 적용 대상을 누릅니다.
| | | |---|---| | 굵기 | 1px · 2px · 3px | | 선종 | 실선 · 파선 · 점선 | | 색 | 기본(격자선) · 먹 · 회색 · 빨강 · 파랑 · 투명 | | 적용 대상 | 모든 · 바깥 · 안쪽 · 위 · 아래 · 왼쪽 · 오른쪽 · 없음 |
펜만 고르면 아무 일도 일어나지 않습니다(팝업이 닫히지 않는 이유입니다). 펜을 기본 · 1px · 실선으로 둔 채 적용 대상을 누르면 그 변들이 원래 격자선으로 되돌아갑니다.
// 호스트가 문서를 만들 때
createTableCell("합계", { borderTop: "3 solid ink", borderRight: "none" })투명과 「없음」은 다릅니다.
none은 선의 자리까지 없애 그 행이 1px 줄지만(실측 38.75 → 37.75), 투명은 자리를 지키고 선만 감춥니다. 표를 흔들지 않고 칸 하나를 비워 보이게 하려면 투명을, 칸 사이를 정말 트려면 「없음」을 씁니다.
값은 낱말 목록입니다 — "<1|2|3> <solid|dashed|dotted> [ink|gray|red|blue|transparent]" 또는 "none".
낱말 순서는 상관없고 모르는 낱말은 버립니다. 색을 빼면 격자선이 그대로 살아 안쪽과 바깥이
같은 선으로 유지됩니다. 색은 다크 테마에서 먹·회색이 뒤집히고 빨강·파랑은 그대로입니다.
한 경계는 한 칸만 그립니다. 저장 JSON 에서 칸의
borderTop·borderLeft는 표 가장자리 칸에서만 쓰입니다 — 그 밖에서는 윗칸의borderBottom, 왼칸의borderRight가 그 경계입니다. 툴바에서 「왼쪽」을 눌러도 값이 왼쪽 이웃 칸에 적히는 이유이고, 그 덕에 한 경계에 값이 하나뿐이라 이웃끼리 두 줄이 그려지는 일이 없습니다.
알아 둘 것 셋
- 굵게 해도 표가 안 흔들립니다. 선은 칸 경계를 걸쳐서 그려지므로(엑셀과 같은 방식) 굵기를 바꿔도 행 높이·표 폭이 그대로이고, 가로선과 세로선이 교차해 모서리가 닫힙니다.
- 내보낸 HTML 도 화면과 같습니다. 테두리를 쓴 표만
border-collapse: separate로 나가고 네 변이 전부 명시됩니다. 안 쓴 표의 출력은 종전과 한 바이트도 다르지 않습니다. 마크다운에는 남지 않습니다.- 다만 굵은 선이 놓이는 자리가 다릅니다. 화면·인쇄는 경계를 걸치지만, 내보낸 HTML 은 받는
쪽이 CSS 만으로 그릴 수 있게
border-*를 그대로 씁니다 — 그쪽에서는 칸 안쪽으로 자라고 점 간격도 브라우저가 칸마다 맞춥니다. 대신 배경을 지우는 소비처(메일 클라이언트 등)에서도 선이 사라지지 않습니다.
- 다만 굵은 선이 놓이는 자리가 다릅니다. 화면·인쇄는 경계를 걸치지만, 내보낸 HTML 은 받는
쪽이 CSS 만으로 그릴 수 있게
- 인쇄는 화면 그대로 따라갑니다.
tables={{ cellBorder: false }} 로 도구를 끄면 툴바에서 그룹째 사라집니다(저장된 값은 계속 그려집니다).
리사이즈 · 스케일 · 정렬
- 열 너비: 열 경계 드래그
- 행 높이: 행 경계 드래그. 저장은 표 속성
content.rowHeights[](물리 행 인덱스, 열 너비와 대칭)이고 셀rowHeight도 함께 실어 옛 판과 호환됩니다. 모든 열이 세로병합된 행은 셀에 실을 칸이 없어 종전에는 높이가 저장되지 않았습니다 — 그 행도 이제 값을 갖습니다 - 표 전체 스케일: 표 우하단 모서리 hover → 대각 드래그로 종횡비를 고정한 채 표 전체를 균일 배율로 확대/축소
- 표 정렬: 표 툴바 「표 정렬 ▾」 · 포매팅 툴바 · 드래그핸들 메뉴에서 표 전체를 좌/가운데/우 정렬
- 에디터 폭 자동 맞춤: 문서를 열 때 표를 본문 폭에 맞춰 그립니다(가로 스크롤 미제공). 표시 전용이라
저장
columnWidths는 원본 값을 지킵니다. 미지정 열(null·0)은 「지정 폭 유지 + 남은 폭 균등 분배」 규칙으로 확정하고, HTML 내보내기도 같은 규칙을 싣습니다.
Excel/Word 붙여넣기
Excel·Word 등에서 복사한 셀 범위를 붙여넣으면(Ctrl+V) 이미지가 아닌 편집 가능한 테이블로 삽입됩니다. 셀 배경색·글자색·정렬·글자 크기·세로 정렬·굵게/기울임/밑줄이 함께 변환됩니다(정확한 hex 색은 10색 팔레트로 근사).
워드 문서의 들여쓰기는 블록 계층을 만들지 않습니다. 한 줄이 한 블록이 되고 전부 같은 깊이로
들어갑니다 — margin-left·text-indent·목록 메타(mso-list level)·선두 공백 중 무엇으로 들여썼든
같습니다. 계층이 필요하면 줄 맨 앞에서 Tab 으로 직접 만듭니다.
예외 둘입니다. <ul>/<ol> 처럼 마크업이 구조로 적은 중첩은 그대로 오고, 첫 줄
들여쓰기(text-indent)는 계층이 아니라 모양이라 props.firstLineIndent 로 보존됩니다(줄 시작
Backspace 로 해제).
글씨체도 따라오지 않습니다. 워드·한글의 font-family 는 붙여넣기에서 떼어내 편집기 기본
글꼴로 통일됩니다(표 셀 포함). 글씨체는 문서의 내용이 아니라 그 프로그램의 기본값이고, 붙인
줄만 다른 서체로 보이는 것이 문서에서 제일 먼저 눈에 띕니다. 글자 크기는 그대로 옵니다 —
사용자가 고른 값일 수 있고 표 셀 크기 충실도가 기능입니다.
편집기에서 복사한 조각은 글씨체를 유지합니다(우리 토큰 data-font-family 로 실립니다).
호스트가 getHTML() 로 내보낸 HTML 을 parseHtmlToBlocks() 로 다시 읽는 길도 글꼴을
잃지 않습니다 — 떼어내는 것은 붙여넣기 경로뿐입니다.
테이블 기능 설정
<LumirEditor
tables={{
splitCells: true, // 셀 병합/분할 (기본 true)
cellBackgroundColor: true, // 셀 배경색 (기본 true)
cellTextColor: true, // 셀 글자색 (기본 true)
headers: true, // 헤더 행/열 (기본 true)
cellBorder: true, // 셀 테두리 도구 (기본 true)
rowDensity: true, // 툴바 「행 높이」 메뉴 (기본 true)
density: "compact", // 셀 여백 축소
cellFontSize: 12, // 셀 기본 글자 크기(px)
cellLineHeight: 1.35, // 셀 줄 간격 배수
}}
tableHandles={true} // 핸들/코너 스케일 전체 게이트 (기본 true, false면 모두 끔)
/>표 셀에 체크박스를 넣으려면 셀에 캐럿을 두고 표 툴바의 체크박스 버튼을 누릅니다. 체크박스가
있는 셀에서는 버튼 오른쪽에 − 12px +가 나타나며 상자 크기만 조절합니다. 값을 비우고 확정하면
인접 글자 크기를 다시 따릅니다. 표 글자 크기는 프리셋 셀렉트 없이 −·직접 입력·+로 지정하며,
입력값을 비우면 기본 크기로 돌아갑니다. 툴바가 본문 단보다 넓어지면 도구가 다음 줄로 감싸집니다.
셀 안에서 드래그해 복사·잘라내기하면 체크박스·목록 마커가 그대로 따라옵니다(붙여넣기도 셀
안쪽만 다시 씁니다 — 열이 늘거나 인접 셀이 바뀌지 않습니다). 복사 시점의 상자 크기가 굳어서
글자 크기가 다른 칸에 붙여도 크기가 변하지 않습니다. 읽기전용·양식 fill에서는 잘라내기·
붙여넣기가 막힙니다.
인라인 체크박스 (표 밖 · 한 블록에 여러 개)
같은 체크박스를 문단·제목·목록 등 일반 블록 안에도 개수 제한 없이 넣을 수 있습니다.
등급: ☐ 대 ☐ 중 ☐ 소처럼 한 줄에 나란히 두거나, 줄바꿈으로 세로로 늘어놓습니다.
| 넣는 법 | 어디서 |
| --- | --- |
| [] 또는 [ ] + 스페이스 | 캐럿 앞에 내용이 있는 모든 자리 |
| 슬래시 메뉴 /체크박스 | 빈 문단(줄 맨 앞에 첫 상자를 놓을 때) |
| 상단 툴바의 체크박스 버튼 | floatingMenu 활성 시, 캐럿 자리 |
「할 일」 블록(checkListItem)과는 다른 물건입니다. 둘은 같은 []+스페이스를 쓰지만 자리로
갈립니다 — 빈 문단 맨 앞이면 할 일 블록이 되고(기존 동작 그대로), 그 밖의 모든 자리에서는
인라인 체크박스가 됩니다. 화면에서도 구분됩니다:
| | 할 일 블록 | 인라인 체크박스 | | --- | --- | --- | | 모양 | 둥근 사각, 완료 시 보라 채움 + 흰 √ | 각진 사각, 완료 시 검정 √(채움 없음) | | 위치·크기 | 줄 왼쪽 거터, 16px 고정 | 글자 흐름 안, 이웃 글자 크기에서 파생 | | 완료 표시 | 본문에 취소선 | 텍스트에 영향 없음 | | 개수 | 블록당 하나 | 블록당 제한 없음 |
클릭하면 체크가 토글되고, Backspace로 지웁니다. 방향키는 상자를 한 번에 건너고(글자 하나와
같습니다) 캐럿은 상자 바깥에 섭니다. 저장 형태는 인라인 노드 { type: "checkbox", checked }이며
표 셀의 것과 같습니다. 마크다운으로 내보낼 때 인라인 상자는 ☐/☑ 문자가 되고, 할 일 블록만
- [ ]로 나갑니다.
Enter로 다음 줄에 이어집니다 — 할 일 목록과 같은 동작입니다. 줄이 상자로 시작하거나 캐럿
직전이 상자면 새 줄도 미체크 상자로 시작하고 캐럿이 그 뒤에 섭니다. 빈 상자 줄에서도 계속
만들어지므로, 빠져나올 때는 Backspace로 상자를 지웁니다. (표 셀 안에서 줄을 바꾸는 키는
Shift+Enter입니다 — Enter는 아래 셀로 이동합니다.)
영역선택 · 복사 · 크기
드래그로 상자를 잡으면 선택 표시가 칠해집니다(글자 없는 원자에는 브라우저가 하이라이트를 그려 주지 않아 직접 칠합니다). 9px 상자처럼 작아서 드래그로 집기 어려우면 Alt+클릭으로 그 상자 하나만 집습니다.
상자가 걸린 선택을 복사·잘라내기하면 상자가 그대로 따라옵니다 — 브라우저 기본 복사는
user-select:none인 원자를 클립보드에서 빼 버리므로(붙여넣으면 흔적 없이 사라집니다) 편집기가
클립을 직접 씁니다. 평문 슬롯에는 [ ]/[x]로 나가고, 앱 안에 붙여넣으면 체크 상태와 상자
크기까지 살아 돌아옵니다.
크기는 서식 툴바의 글자 크기 입력 하나로 조절합니다:
| 선택 | 입력을 바꾸면 | 저장 |
| --- | --- | --- |
| 글자 + 상자 | 글자 크기가 바뀌고 상자가 따라옵니다 | 상자에 크기를 적지 않습니다(이웃 글자에서 파생) |
| 상자만 (Alt+클릭·드래그) | 그 상자 크기만 바뀝니다(글자 불간섭) | { type:"checkbox", checked, size } |
| 상자만 + 프리셋 「기본」 | 명시 크기를 지워 다시 글자를 따릅니다 | size 제거 |
고정 레이아웃 문서 (lockMode)
관리자가 만든 틀은 그대로 두고 지정한 칸만 사용자가 채우는 모드입니다. lockMode를 생략하면
기존 편집 동작이 한 비트도 바뀌지 않습니다. lockMode는 마운트 전용이므로 역할을 바꾸려면
편집기를 다시 마운트해야 합니다.
// 설계: 자유 입력 칸·행 추가 허용을 지정한다(표/서식 툴바)
<LumirEditor initialContent={blocks} lockMode={{ role: "design" }} />
// 작성: 지정한 칸 밖은 전부 잠긴다
<LumirEditor initialContent={blocks} editable lockMode={{ role: "fill" }} />
// 열람
<LumirEditor initialContent={blocks} lockMode={{ role: "view" }} />fill은 editable={true}와 함께 사용합니다. view로 마운트한 문서는 setEditable(true)로 열 수
없습니다. 모르는 role은 경고 후 view로 다룹니다 — 조용히 "모드 없음"이 되지 않습니다.
어디를 열지는 문서가 정한다
| 자리 | prop | 지정 방법 |
| --- | --- | --- |
| 자유 입력 칸(표 셀) | tableCell.props.formEditable | 표 툴바 「자유 입력 칸」 |
| 자유 입력 문단 | block.props.formEditable | 서식 툴바 「자유 입력 칸」 |
| 행 추가 허용(표) | table.props.allowRowEdit | 표 툴바 「행 추가 허용」 |
세 표식은 design에서 찍거나 호스트가 문서 JSON에 직접 심습니다. fill에서 늘어나는 행은 빈 행이
아니라 위 행의 표식을 물려받은 새 행이라 바로 입력할 수 있고, 표식이 없는 표에는 「+」 바가
아예 서지 않습니다. 열 추가·병합·리사이즈는 fill에서 전부 막힙니다.
체크박스(인라인 원자·체크리스트)는 fill에서 토글됩니다 — 체크는 레이아웃을 바꾸지 않기
때문입니다. 값 변경은 onContentChange로 나갑니다.
자세한 배선은 docs/lock-mode/GUIDE.md를 참고하세요.
양식 필드 바인딩 — 동결 (0.9.0)
formMode와 아마란스 바인딩 계약({ITEMS, TABLE} 봉투 · mapping_key · 키 카탈로그 · 필드
속성 패널)은 봉인됐습니다. 새 문서에 필드를 심을 수 없고, formMode를 주면 경고 후 role만
lockMode로 옮겨 처리합니다(author→design). 잠금이 조용히 풀리는 경로는 없습니다.
formMode.fieldKeys와 onFieldChange는 아무 동작도 하지 않습니다(경고만).
이미 저장된 문서는 그대로 돕니다. 필드 원자는 왕복하고, 아래 순수 JSON API도 0.7.0과 같은
동작을 유지합니다 — 제거하지 않았습니다. 새 작업은 lockMode 또는 데이터 자리 칩(insertChip)을
쓰세요.
import { extractFormData, applyFormData, validateFormData } from "@lumir-company/editor/core";
// applyFormDataWithReport · bindingReport · validateFieldKeys · markValidationErrors 도 그대로 있습니다.데이터 자리 칩 (chip)
양식을 설계할 때 "이 자리에 어떤 데이터가 들어온다"를 문서 안에 보여 주는 인라인 표식입니다.
알약에 라벨 + 타입명이 들어가고(보안서약 boolean) 값은 담지 않습니다.
// 1) 슬래시 메뉴로 고르게 한다 — 카탈로그는 호스트가 준다
<LumirEditor
initialContent={blocks}
chipCatalog={[
{ key: "emp_name", label: "성명", vtype: "string" },
{ key: "security_oath", label: "보안서약", vtype: "boolean" },
]}
/>
// 2) 호스트 패널에서 직접 심는다
ref.current?.insertChip({ key: "emp_name", label: "성명", vtype: "string" });- 저장 형태는
{ type:"chip", key, label?, vtype? }. 칩이 없는 문서는 한 비트도 달라지지 않습니다. - 타입명은 보내신
vtype이 그대로 나옵니다 — 편집기는 타입 어휘를 갖지 않고, 값으로 타입을 추측하지도 않습니다(불리언을'Y'/'N'문자열로 보내면string으로 뜹니다). 색은string초록 ·number갈색 ·boolean인디고 · 그 밖 전부 회색입니다. insertChip은 캐럿이 문단·표 셀 안에 없으면 아무것도 하지 않고null을 돌려줍니다.- 슬래시 「데이터 필드」 그룹은
chipCatalog를 준 경우에만 뜹니다(문단·표 셀 양쪽). 런타임 교체는setChipCatalog(list)입니다 — 편집기는 카탈로그를 갖지 않습니다. - 크기는 지정이 없으면 셀·문단의 0.7배이고, 서식·표 툴바의 글자 크기 컨트롤로 그 칩만 바꿉니다. 지우기는 Backspace로 통째로입니다.
- 내보내기: HTML은
data-chip-key·data-chip-vtype과 글자를, 마크다운은라벨 (타입)을 남깁니다.{{key}}마커로는 나가지 않습니다. - 저작 UI는 편집기가 갖지 않습니다 — 필드 목록·속성 패널은 양식을 만드는 쪽 소관이고,
lockMode·formMode와 무관합니다.
HTML 양식 템플릿
호스트가 HTML 카탈로그를 주입하면 슬래시 메뉴에 양식별 항목이 나타납니다. 사용자가 편집할 위치는
data-lumir-slot으로 표시합니다. 템플릿은 Shadow DOM에 격리되고 렌더·내보내기마다 정화됩니다.
const htmlTemplates = [{
id: "expense-v1",
name: "지출결의서",
version: "1",
html: `
<style>.title { font-weight: 700; }</style>
<section>
<h1 class="title">지출결의서</h1>
신청자: <span data-lumir-slot="requester"
data-lumir-slot-placeholder="이름 입력"></span>
</section>`,
}];
<LumirEditor htmlTemplates={htmlTemplates} htmlFileDrop />;배포 전에 템플릿 정화 결과와 슬롯을 검사할 수 있습니다. 이 API는 DOM을 사용하므로 브라우저에서 실행합니다.
import { inspectHtmlTemplate } from "@lumir-company/editor";
const result = inspectHtmlTemplate(htmlTemplates[0].html);
result.ok; // 제거된 위험 요소가 없으면 true
result.slots; // ["requester"]
result.removed; // 예: ["tag:script", "attr:onerror"].html·.htm 파일 드롭은 기본 활성화되어 있으며 htmlFileDrop={false}로 끌 수 있습니다.
슬롯이 없는 HTML은 편집 지점이 없는 정적 임베드로 삽입됩니다.
호스트 연동 API
저장 콘텐츠 판정과 블록 팩토리
import {
parseDocumentContent,
createTextRun, createParagraph, createHeading,
createTableCell, createTableRow, createTable,
} from "@lumir-company/editor/core";
const parsed = parseDocumentContent(savedValue);
if (!parsed.ok) {
parsed.reason; // "empty" | "not-json" | "not-blocks"
} else {
parsed.blocks;
}
const table = createTable([
createTableRow([
createTableCell("품목"),
createTableCell([createTextRun("수량", { bold: true })]),
]),
]);
const doc = [createHeading("신청서", { level: 2 }), createParagraph("내용"), table];링크 클릭 가로채기
<LumirEditor
onLinkOpen={(href, event, anchor) => {
openPopup(href);
return true; // 기본 새 탭 열기 생략
}}
/>onLinkOpen은 읽기전용에서도 동작합니다. true 이외를 반환하면 기존처럼 새 탭으로 엽니다.
다단 컬럼 (2·3단)
노션식 다단 컬럼 레이아웃입니다. 공식 @blocknote/xl-multi-column(AGPL) 대신 MIT 자체 구현을 사용합니다. 2단·3단을 지원합니다.
- 삽입: 슬래시 메뉴
/2단 컬럼·/2단 컬럼 (구분선)·/3단 컬럼·/3단 컬럼 (구분선). - 블록 DnD 생성: 블록을 다른 블록의 좌/우 가장자리로 끌어다 놓으면 2단 컬럼이 생성됩니다(세로 드롭 인디케이터). (3단은 슬래시 메뉴로 생성)
- 컬럼 간 이동 / 컬럼 밖으로 내보내기: 블록 그립(⠿)을 다른 컬럼 안으로 드래그하면 그 컬럼으로, 컬럼 바깥(최상위 블록의 위/아래) 으로 드래그하면 컬럼을 벗어나 일반 블록이 됩니다. 드롭 인디케이터가 컬럼 폭이면 컬럼 안, 에디터 전체 폭이면 최상위입니다.
- 다단 블록 자체 이동: 다단 블록의 첫 컬럼 첫 블록 자리(좌상단) 가 "블록 전체" 그립입니다. 그 자리에 커서를 두면(본문이든 왼쪽 거터든) 항상 columnList 그립이 잡히고, 드래그하면 다단 블록이 통째로 최상위에서 재정렬됩니다. 컬럼 사이 여백에서도 같은 그립이 잡힙니다.
그 자리에서는 첫 컬럼 첫 블록 개별 그립을 쓸 수 없습니다(두 그립이 같은 좌표라 구분이 불가능해, 묶음 이동으로 통일). 그 블록만 옮기려면 다른 블록을 옮겨 자리를 바꾸거나, 컬럼 안에서 Enter/Backspace로 편집하세요. 첫 컬럼의 두 번째 이후 블록과 다른 컬럼의 블록은 모두 자기 그립을 그대로 씁니다.
- 중첩 금지: 다단 블록을 다른 다단 블록의 컬럼 안에 넣을 수 없습니다(컬럼 안의 컬럼은 지원 범위 밖).
- 컬럼 탈출: 컬럼 맨 마지막 블록에서 Enter 두 번 → 컬럼 아래에 일반 문단 생성·포커스(어느 컬럼에서든 동일).
- 컬럼 축소: 한 컬럼의 마지막 블록이 그 컬럼을 떠나면 컬럼이 접힙니다 — 3단 → 2단, 2단 → 컬럼 없는 일반 블록행(언랩). 사라진 컬럼의 폭은 남은 컬럼에 비례 분배되어 상대 비율이 유지됩니다. "떠난다"는 세 경로 모두를 뜻합니다:
- 그립(⠿) 드롭다운의 삭제
- 컬럼에 하나만 남은 빈 블록에서 Backspace
- DnD로 다른 컬럼으로 이동
- 너비 리사이즈: 컬럼 사이 경계를 드래그해 인접 두 컬럼의 비율 조절(합 유지).
- 구분선:
- 블록별: 생성 시 "구분선" 항목을 고르면 그 블록에 고정되어 저장·라운드트립(
showDivider). - 전역:
columnDividerprop(기본false)으로 모든 컬럼 사이에 세로 구분선 표시.
- 블록별: 생성 시 "구분선" 항목을 고르면 그 블록에 고정되어 저장·라운드트립(
<LumirEditor columnDivider />구분선 색은 CSS 변수 --lumir-column-divider-color로 조절합니다(미지정 시 라이트 --lumir-line #CED8E5 / 다크 #4a4a4a). 구분선 양옆에는 블록 그립이 뜰 여백(약 18–20px)이 예약됩니다.
좁은 화면(640px 이하)에서는 컬럼이 세로로 쌓입니다(0.13.0). 잡을 이유가 없는 리사이저는 숨습니다.
하위호환: 4단 이상(레거시/외부 데이터)으로 저장된
columnList는 로드 시 초과 컬럼의 블록이 마지막(3번째) 컬럼으로 병합되어 3단으로 정규화됩니다. 1단은 펼쳐서 일반 블록으로 승격됩니다.
여러 줄 선택
블록 경계를 넘는 드래그 · Shift+방향키 · Shift+클릭으로 여러 블록을 한 번에 잡습니다.
잡은 범위에는 일괄 서식 · 복사 · 삭제 · 타이핑 · 붙여넣기가 걸리고, 되돌리기는 한 번입니다.
블록 도구도 그대로 걸립니다(0.14.3) — 블록 유형 · 정렬 · 들여쓰기 넷이 선택된 블록 전부에 적용됩니다. 링크만 단일 블록 전용입니다(블록을 넘는 링크는 저장 모델에 없습니다). 유형·정렬 표시는 선택이 한 가지 값으로 모일 때만 켜지고, 섞였으면 비워 둡니다 — 빈 표시는 「모른다」가 아니라 「여러 값」이라는 뜻입니다.
들여쓴 블록도 범위 안입니다(0.13.0). 목록 아래 본문처럼 한 단 들어간 블록이 최상위 문단과 똑같이 참여합니다 — 종전에는 여기서 잘려 한 줄만 잡혔습니다.
| 참여 | 대상 | | --- | --- | | 된다 | 문단·제목·목록·인용·코드 — 들여쓴 것 포함 | | 안 된다 | 표 셀 · 2·3단 컬럼 안 · HTML 양식 슬롯 — 각각 독립 편집 영역이라 경계를 넘지 않습니다 |
선택이 들여쓰기 경계를 넘고 그 안을 지우면, 선택 밖에 남는 자식 블록은 병합된 블록 아래로 옮겨집니다 — 사라지지 않습니다.
복사·잘라내기·붙여넣기·되돌리기 버튼
여러 줄을 잡으면 서식 툴바 오른쪽 끝에 네 버튼이 붙습니다(0.14.0).
왜 버튼이 필요한가: 여러 줄 선택은 브라우저의 선택이 아니라 편집기가 그린 하이라이트입니다.
그래서 우클릭 메뉴의 복사·잘라내기가 회색으로 보입니다 — 브라우저 눈에는 선택이 없기 때문입니다.
단축키(Ctrl+C/X/V/Z)는 편집기가 직접 받으므로 종전대로 동작합니다.
| 버튼 | 비고 |
| --- | --- |
| 복사 · 잘라내기 | 여러 줄 선택도 그대로 실립니다(서식 보존 HTML + 평문) |
| 붙여넣기 | 브라우저가 클립보드 읽기 권한을 묻습니다(크롬은 처음 한 번). 막히면 안내가 뜨고, Ctrl+V 는 그대로 됩니다 |
| 되돌리기 | 선택이 있을 때 보입니다. 선택 없이 쓰려면 Ctrl+Z 또는 상단 고정 툴바(floatingMenu) |
글자 크기
텍스트를 선택한 뒤 포매팅 툴바 또는 상단 고정 툴바(FloatingMenu) 의 글자 크기 컨트롤로 인라인 크기를 변경합니다.
- 프리셋: 기본(14px, 스타일 제거) / 10 · 12 · 14 · 16 · 18 · 20 · 24 · 28 (px)
- 1px 스테퍼: 드롭다운 상단의
−/+버튼과 직접 입력(↑/↓키)으로 8~96px 범위 내 임의 값 지정. 명시 크기가 없으면 14px 기준으로 증감 - 테이블 셀 텍스트에도 동일하게 적용됩니다.
- 외부 HTML(웹페이지·Excel 등)을 붙여넣을 때의 글자 크기는 가져오지 않습니다.
0.13.0부터 편집기가 기본 타이포를 스스로 세웁니다 —
.lumir-editor에font-size: 14px·line-height: 1.7· Pretendard 스택이 선언됩니다. 종전에는 호스트 페이지에서 상속했으므로, 16px 페이지에 얹고 있었다면 모든 문서가 작아집니다. 상속으로 되돌리려면 덮으세요..lumir-editor { font-size: inherit; line-height: 1.6; font-family: inherit; }
하위호환 직렬화 포맷 (중요)
글자 크기는 저장 JSON에서 styles 맵이 아니라 styled-text의 형제(sibling) 키 fontSize 로 직렬화됩니다.
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "큰 글씨", "styles": { "bold": true }, "fontSize": "18px" }
]
}이유: BlockNote는 styles 맵에 스키마에 없는 키가 있으면 예외를 던집니다. styles.fontSize로 저장하면 fontSize 스펙이 없는 구버전 SDK(≤0.4.15) 가 그 JSON을 로드할 때 크래시합니다. 형제 키 방식은 구버전에서 조용히 무시되어(글자 크기만 미표시) 안전하게 로드됩니다.
- 에디터 로드/저장 시 변환은 자동입니다(
initialContent↔onContentChange). - 외부 렌더러에서 저장 JSON을 직접 다룬다면, 공개 export된
liftFontSize(blocks)로 형제 키를styles.fontSize로 복원한 뒤 사용하세요. 반대로 외부로 내보낼 때는 반드시lowerFontSize(blocks)를 거쳐야 합니다(styles.fontSize가 유출되면 구버전 소비 앱이 크래시). - 직렬화 형태 타입은
SerializedStyledText로 export됩니다.
링크
URL을 텍스트에 붙여넣으면 자동으로 인라인 링크로 변환됩니다(구버전의 자동 OG 카드 생성은 제거됨).
- 링크 툴바(Notion식): 링크에 hover하면 URL 툴팁이 뜨고, 클릭하면 URL·텍스트를 편집하는 popup이 열립니다.
- 보안:
javascript:·data:·vbscript:·file:등 위험 프로토콜은 차단됩니다. linkToolbarprop(기본true)으로 링크 툴바 표시를 제어합니다.
구버전의
linkPreviewprop과/api/link-preview서브패스는 제거되었습니다.
Placeholder
에디터가 비어있을 때 안내 텍스트를 표시합니다.
<LumirEditor placeholder="내용을 입력하세요..." />- 빈 블록에 연한 색으로 표시되고, 입력을 시작하면 사라집니다.
- 모든 빈 블록(첫 블록 포함)에 동일하게 적용됩니다.
테마
<LumirEditor theme="dark" />
<LumirEditor theme="light" />
<LumirEditor theme={{ /* 커스텀 테마 객체 */ }} />theme은 "light" | "dark" | 커스텀 테마 객체를 받습니다(기본 "light").
다크는 0.13.0 개편의 범위 밖입니다. 편집기 크롬(툴바·팝업·메뉴)은 새 라이트 토큰을 쓰고 본문은 옛 다크 규칙을 그대로 씁니다 — 섞입니다. 읽을 수 없게 된 대비만 고쳤고, 다크 재설계는 다음 판입니다.
Props API
자주 쓰는 Props
| Prop | 타입 | 기본값 | 설명 |
| --- | --- | --- | --- |
| initialContent | DefaultPartialBlock[] \| string | undefined | 초기 콘텐츠(블록 배열 또는 JSON 문자열) |
| onContentChange | (blocks: DefaultPartialBlock[]) => void | undefined | 콘텐츠 변경 콜백 |
| s3Upload | S3UploaderConfig | undefined | S3 업로드 설정 |
| uploadFile | (file: File) => Promise<string> | undefined | 커스텀 업로드 함수 |
| onImageDelete | (url: string) => void | undefined | 이미지·비디오 삭제 콜백 |
| onError | (error: LumirEditorError) => void | undefined | 에러 콜백 |
| editable | boolean | true | 편집 가능 여부 |
| placeholder | string | undefined | 빈 블록 안내 텍스트 |
| theme | "light" \| "dark" \| object | "light" | 테마 |
| allowVideoUpload | boolean | false | 동영상 업로드 허용 |
| tables | TableConfig | 모두 true | 테이블 기능 |
| lockMode | { role, allowTrailingParagraph? } | undefined | 고정 레이아웃 문서 — design·fill·view(마운트 전용) |
| formMode | { role, … } | undefined | 봉인 — role만 lockMode로 매핑됩니다(경고) |
| chipCatalog | Array<{ key, label?, vtype? }> | undefined | 데이터 자리 칩 카탈로그 — 슬래시 「데이터 필드」 그룹 |
| htmlTemplates | HtmlTemplateEntry[] | undefined | HTML 양식 카탈로그 |
| htmlFileDrop | boolean | true | .html·.htm 파일 드롭 허용 |
| onFieldChange | (key, value, at?) => void | undefined | 봉인 — 호출되지 않습니다. onContentChange를 쓰세요 |
| onLinkOpen | (href, event, anchor) => boolean \| void | undefined | 본문 링크 클릭 가로채기 |
| maxImageFileSize | number | 10MB | 이미지 최대 용량(바이트) |
| maxVideoFileSize | number | 100MB | 동영상 최대 용량(바이트) |
| className | string | "" | 컨테이너 CSS 클래스 |
전체 Props
interface LumirEditorProps {
// 콘텐츠
initialContent?: DefaultPartialBlock[] | string;
initialEmptyBlocks?: number; // 초기 빈 블록 개수 (기본 3)
placeholder?: string;
// 업로드
uploadFile?: (file: File) => Promise<string>;
s3Upload?: S3UploaderConfig;
allowVideoUpload?: boolean; // 기본 false
allowAudioUpload?: boolean; // 기본 false
allowFileUpload?: boolean; // ⚠ 미구현 — @deprecated (콘솔 경고)
maxImageFileSize?: number; // 미설정 시 10MB
maxVideoFileSize?: number; // 미설정 시 100MB
maxAudioFileSize?: number;
// 기능
tables?: {
splitCells?: boolean;
cellBackgroundColor?: boolean;
cellTextColor?: boolean;
headers?: boolean;
rowDensity?: boolean;
density?: "normal" | "compact";
cellFontSize?: number;
cellLineHeight?: number;
};
heading?: { levels?: (1 | 2 | 3 | 4 | 5 | 6)[] };
defaultStyles?: boolean; // ⚠ 미구현 — @deprecated
disableExtensions?: string[]; // ⚠ 미구현 — @deprecated
trailingBlock?: boolean; // 기본 true
trailingBlockType?: string; // ⚠ 미구현 — @deprecated
// UI
editable?: boolean; // 기본 true
theme?: "light" | "dark" | Record<string, unknown>; // 기본 "light"
formattingToolbar?: boolean; // 기본 true
linkToolbar?: boolean; // 기본 true
sideMenu?: boolean; // 기본 true
sideMenuAddButton?: boolean; // ⚠ 미구현 — @deprecated
slashMenu?: boolean; // 기본 true
emojiPicker?: boolean; // 기본 true
filePanel?: boolean; // 기본 true
tableHandles?: boolean; // 기본 true
toolbarTopOffset?: number; // 고정 헤더 아래 표 툴바 안전선(px)
columnDivider?: boolean; // 2단 컬럼 세로 구분선 (기본 false)
floatingMenu?: boolean; // 상단 고정 툴바 (기본 false)
floatingMenuPosition?: "sticky" | "fixed"; // 기본 "sticky"
className?: string;
// 로케일/기타
locale?: string | LumirLocale; // "ko" | "en" 등
resolveFileUrl?: (url: string) => string | Promise<string>;
fetchLinkPreview?: (url: string) => Promise<LinkPreview> | LinkPreview; // 링크 카드 메타 조회(서버 프록시 권장)
htmlTemplates?: HtmlTemplateEntry[]; // HTML 양식 카탈로그
htmlFileDrop?: boolean; // 기본 true
lockMode?: {
role: "design" | "fill" | "view";
allowTrailingParagraph?: boolean;
};
formMode?: { role: "author" | "fill" | "view" }; // 봉인 — role 만 lockMode 로 매핑
chipCatalog?: Array<{ key: string; label?: string; vtype?: string }>; // 데이터 자리 칩 카탈로그
showErrorToast?: boolean;
// 콜백
onContentChange?: (blocks: DefaultPartialBlock[]) => void;
onError?: (error: LumirEditorError) => void;
onSelectionChange?: () => void;
onImageDelete?: (url: string) => void;
onFieldChange?: (key: string, value: string, at?: { group: string; index: number }) => void; // 봉인
onLinkOpen?: (href: string, event: MouseEvent, anchor: HTMLAnchorElement) => boolean | void;
onUploadStart?: (file: File) => void;
onUploadEnd?: (file: File) => void;
}미구현 옵션 다섯(
allowFileUpload·defaultStyles·disableExtensions·sideMenuAddButton·trailingBlockType)은 타입에만 있고 아무 동작도 하지 않습니다. 0.13.0에서@deprecated표시와 콘솔 경고를 붙였고 다음 메이저에서 제거합니다. 특히allowFileUpload: false는 업로드를 막은 적이 없습니다 — 막으려면uploadFile/s3Upload를 주지 않습니다.
S3UploaderConfig
interface S3UploaderConfig {
apiEndpoint: string; // Presigned URL API 엔드포인트
env: "development" | "production";
path: string; // S3 저장 경로
fileNameTransform?: (nameWithoutExt: string, file: File) => string;
appendUUID?: boolean; // 파일명 뒤(확장자 앞)에 UUID 추가
preserveExtension?: boolean; // 기본 true. false면 확장자 미부착
onProgress?: (percent: number) => void; // 0~100, S3 PUT 시만 호출(보간)
uploadTimeoutMs?: number; // PUT 타임아웃. 기본 120000
maxRetries?: number; // PUT 재시도. 기본 2
}React ref (imperative API)
import { useRef } from "react";
import { LumirEditor, type LumirEditorReactRef } from "@lumir-company/editor";
const ref = useRef<LumirEditorReactRef>(null);
<LumirEditor ref={ref} />;
ref.current?.getDocument(); // DefaultPartialBlock[] | null
ref.current?.setContent(blocks); // 배열 또는 저장 JSON. 성공 여부 반환
ref.current?.getHTML();
ref.current?.getMarkdown();
ref.current?.undo(); ref.current?.redo();
ref.current?.canUndo(); ref.current?.canRedo();
ref.current?.insertFile(file);
ref.current?.setTheme("dark");
ref.current?.setEditable(false);
ref.current?.insertChip({ key, label, vtype }); // Element | null
ref.current?.setChipCatalog(list); // 슬래시 카탈로그 교체
ref.current?.element; // 편집기 루트 HTMLElement | null
ref.current?.editor; // 내부 VanillaEditor 인스턴스react-free 코어 API
mountLumirEditor(host, options)는 ./core(또는 루트)에서 import하며 VanillaEditor 인스턴스를 반환합니다.
import { mountLumirEditor, type VanillaEditor } from "@lumir-company/editor/core";
const editor: VanillaEditor = mountLumirEditor(hostEl, options /* LumirEditorProps */);VanillaEditor 인스턴스 메서드
| 메서드 | 반환 | 설명 |
| --- | --- | --- |
| getDocument() | DefaultPartialBlock[] | 현재 블록 JSON |
| getHTML(opts?) | string | 콘텐츠 HTML 조각({ inlineDefaults } — 아래) |
| getFullHTML(opts?) | string | <html> 문서 HTML({ title, style }) |
| getMarkdown() | string | 콘텐츠 Markdown |
| setBlocks(blocks) | void | 블록 교체 |
| setMarkdown(md) | void | Markdown으로 콘텐츠 설정 |
| commit() | boolean | 편집 커밋 |
| undo() / redo() | boolean | 실행 취소/재실행 |
| canUndo() / canRedo() | boolean | 가능 여부 |
| insertFile(file) | void | 파일 삽입(업로드) |
| isEditable() / setEditable(on) | — | 편집 가능 상태 |
| setTheme(theme) | void | 테마 변경 |
| insertChip(node) | Element | null | 캐럿 자리에 데이터 자리 칩 삽입(자리 없으면 null) |
| setChipCatalog(list) | void | 슬래시 「데이터 필드」 카탈로그 교체 |
| setOnContentChange(fn) | void | 변경 콜백 교체 |
| destroy() | void | 정리(리스너 해제) |
getHTML({ inlineDefaults }) — 조각을 자기 CSS 없이 띄울 때
getHTML() 은 조각을 돌려줍니다. 표를 감싸는 CSS 는 호스트 몫이고, 그 때문에 한 가지가 갈립니다 —
세로정렬입니다. 편집 화면은 셀을 위 정렬로 두지만(에디터 CSS 가 강제) 조각에는 그 지정이 없어,
받는 쪽 브라우저 기본값인 가운데 정렬로 뜹니다.
editor.getHTML(); // 기본 — 종전 출력 그대로
editor.getHTML({ inlineDefaults: true }); // 셀마다 vertical-align:top 을 찍는다- 호스트 뷰어에
td, th { vertical-align: top }이 이미 있으면 켤 필요가 없습니다. - 켜면 표 하나당 약 1KB 늘어납니다(실물 양식 기준 셀의 81%가 세로정렬 무지정).
- 명시한 값은 절대 덮어쓰지 않습니다 — 지정이 언제나 이깁니다.
getFullHTML()은 자기 스타일블록을 들고 가므로 이 옵션이 필요 없습니다.
직렬화·변환 유틸
블록 JSON ↔ HTML ↔ Markdown ↔ DOM 변환 함수가 공개 export됩니다.
import {
blocksToHtml, blocksToFullHtml, parseHtmlToBlocks,
blocksToMarkdown, markdownToBlocks,
blocksToDom, domToBlocks, blockToElement, elementToBlock,
inlineContentToHtml, htmlToInlineContent, blocksFromText,
} from "@lumir-company/editor/core";
const html = blocksToHtml(blocks);
const blocks2 = parseHtmlToBlocks(html);
const md = blocksToMarkdown(blocks);색상·표 모델(TableModel, newTableModel, modelToTableContent 등) 저수준 유틸도 함께 export됩니다. 전체 목록은 타입 선언(dist/core.d.ts)을 참고하세요.
유틸리티 API
ContentUtils
import { ContentUtils } from "@lumir-company/editor";
ContentUtils.isValidJSONString('[{"type":"paragraph"}]'); // boolean
ContentUtils.parseJSONContent(jsonString); // DefaultPartialBlock[] | null
ContentUtils.createDefaultBlock(); // DefaultPartialBlock
ContentUtils.validateContent(content, emptyBlockCount); // DefaultPartialBlock[]
ContentUtils.createEmptyBlocks(3); // DefaultPartialBlock[]글자 크기 유틸
import {
liftFontSize, lowerFontSize,
FONT_SIZE_PRESETS, FONT_SIZE_MIN, FONT_SIZE_MAX, FONT_SIZE_DEFAULT_PX, FONT_SIZE_STEP,
parseFontSizePx, clampFontSizePx, toFontSizeValue,
} from "@lumir-company/editor";색상 상수
import { TEXT_COLORS, BACKGROUND_COLORS, getHexFromColorValue } from "@lumir-company/editor";
// BACKGROUND_COLORS 12색(배경 전용 yellow·gray-strong 포함) · TEXT_COLORS 10색0.13.0에서 값 목록·순서·hex 가 모두 바뀌었습니다. 이 배열로 자기 색 UI 를 그린다면 0.12.x → 0.13.0 마이그레이션을 보세요.
getHexFromColorValue는 없어진 값(brown등)에도 옛 hex 를 계속 돌려줍니다 — 저장된 문서가 무색으로 나가지 않게 하려는 것입니다.
에러
import { LumirEditorError, LUMIR_ERROR_CODES } from "@lumir-company/editor";
// code: "UPLOAD_FAILED" | "INVALID_FILE_TYPE" | "S3_CONFIG_ERROR" | "NETWORK_ERROR" | "CONTENT_PARSE_ERROR" | "UNKNOWN_ERROR"로케일
import { KO, EN, LOCALES, resolveLocale } from "@lumir-company/editor";
<LumirEditor locale="ko" />; // 또는 locale={EN}기타
createS3Uploader, generateUUID, cn(className 결합) 등도 export됩니다.
스타일링
@lumir-company/editor/style.css가 모든 스타일을 단일 번들로 포함합니다(별도 CSS import 불필요).
Tailwind CSS
import { LumirEditor, cn } from "@lumir-company/editor";
<LumirEditor
className={cn(
"min-h-[400px] rounded-xl border border-gray-200 shadow-lg",
"focus-within:ring-2 focus-within:ring-blue-500",
)}
/>;커스텀 CSS — 공개 셀렉터 계약
편집기 안쪽을 CSS 로 손볼 때 아래 이름만 짚으세요. 여기 있는 이름은
__tests__/public-selectors.test.js 가 실제 DOM 에서 잡히는지 고정합니다 — 바뀌면 그 테스트가
먼저 실패합니다. 표에 없는 내부 클래스는 예고 없이 바뀝니다.
| 셀렉터 | 무엇 |
| --- | --- |
| .lumir-editor | 편집기 루트 |
| .lumir-editor.lumir-readonly | editable: false 상태 |
| .lumir-editor.lumir-form-author / .lumir-form-fill | lockMode 설계 / 작성 모드(클래스 이름은 0.7.0 그대로) |
| .lumir-react-host | React 래퍼가 만드는 바깥 <div> |
| .block[data-type="paragraph"…] | 블록 래퍼 |
| .block > .content | 블록의 편집 영역 |
| table.lumir-tbl | 표 |
| table.lumir-tbl.lumir-tbl--compact | 열폭이 40px 미만이라 자동으로 좁혀진 표 |
| td.lumir-cell | 표 셀 |
| td.lumir-cell[data-bd-top|-right|-bottom|-left] | 사용자가 지정한 셀 테두리(값은 낱말 목록) — 지정한 변에만 섭니다 |
| .lumir-field[data-field-key] | 양식 필드 원자(data-field-kind·data-field-state 동반) — 저장된 문서에만 |
| [data-form-editable="1"] | 자유 입력 영역(셀·블록) |
| .block[data-allow-row-edit] | 행 추가를 허용한 표 |
| .block[data-repeat-group] · td[data-repeat-row] · td[data-repeat-instance] | 반복 그룹 표식(동결 — 새로 찍히지 않습니다) |
| .lumir-slot | htmlTemplate 블록의 편집 슬롯 |
호환 별칭 — 구 BlockNote 기반 패키지의 CSS 를 살려 두려고 함께 내보냅니다. 새 코드에서는 위 이름을 쓰세요.
| 별칭 | 대응 |
| --- | --- |
| .bn-editor | .lumir-editor (같은 요소) |
| .bn-container | .lumir-react-host (같은 요소) |
| [data-content-type="..."] | .block[data-type="..."] (같은 요소) |
⚠
.lumirEditor(camelCase)는 이 패키지에 존재한 적이 없습니다. 그 이름을 짚은 규칙은 에러 없이 조용히 죽습니다 — 실제로 그 때문에 열이 많은 표가 셀마다 한 글자씩 끊긴 채 운영된 사고가 있었습니다..lumir-editor(하이픈)가 맞습니다.
.my-editor .lumir-editor {
padding: 20px 30px;
line-height: 1.6;
}
.my-editor .block[data-type="heading"] {
font-weight: 700;
}<LumirEditor className="my-editor" />표 글자 크기는 CSS 로 덮지 마세요 — tables.cellFontSize 옵션이 있습니다(아래).
CSS 로 덮으면 사용자가 그 셀에서 지정한 글자 크기까지 함께 눌립니다.
표 행 높이
표를 클릭하면 뜨는 툴바의 「행 높이」 에서 고릅니다 — 보통(기본) · 좁게 · 촘촘히. 절차서처럼 한 장에 담아야 하는 문서를 위한 손잡이입니다.
| 단계 | 셀 여백 | 셀 줄 간격 |
|---|---|---|
| 보통(기본) | 9px / 10px | 1.5 |
| 좁게 | 6px / 8px | 1.4 |
| 촘촘히 | 4px / 6px | 1.3 |
문서에 남습니다. 값은 표 블록의 props.rowDensity 로 저장돼, 결재자가 열어도 같은 자리에서
페이지가 끊깁니다. 내보낸 HTML 과 인쇄도 화면과 같은 수치입니다.
안 고른 표는 렌더가 한 픽셀도 바뀌지 않습니다. 「보통」은 기본값을 같은 값으로 맞추는 것이 아니라 값을 지웁니다 — 그러면 밀도 규칙이 아예 걸리지 않습니다.
행 높이를 끌어 정해 둔 표에서는 마지막에 한 동작이 이깁니다.
- 밀도를 고른 뒤 행 경계를 끌면 → 끈 높이가 남습니다
- 행 경계를 끈 뒤 밀도를 고르면 → 끈 높이가 지워지고 밀도가 정한 높이로 돌아갑니다
행 높이는 「최소 높이」라 지우지 않으면 밀도를 낮춰도 아무 일도 일어나지 않기 때문입니다.
되돌리려면 Ctrl+Z 입니다.
도구를 감추려면 tables={{ rowDensity: false }} 입니다. 호스트가 표 전체를 좁히는 아래
tables.density 와는 다른 축이고, 표마다 정한 값이 그쪽을 이깁니다.
표 밀도·글자 크기
열이 많은 표(결재 품의 품목 등)를 한 장에 담아야 할 때 씁니다. 주지 않으면 렌더가 한 픽셀도 바뀌지 않습니다.
<LumirEditor
tables={{
density: "compact", // 셀 패딩 9/10 → 4/6, 글자 12.5 → 11.5px
cellFontSize: 10, // 셀에만 적용 — 사용자가 지정한 글자 크기는 자기 값을 지킨다
cellLineHeight: 1.3, // 선택(cellFontSize 를 줬을 때 기본 1.35)
}}
/>density 는 열폭에서 자동으로 파생되는 .lumir-tbl--compact 와 별개 축입니다.
둘 다 걸리면 각자 자기 몫을 합니다.
표 셀의 기본값은 글자 12.5px · 줄 간격 1.5 · 패딩 9/10 입니다. 0.13.0에서 셀이 자기 값을
갖게 됐습니다 — 종전에는 본문에서 상속했습니다.
인쇄
import "@lumir-company/editor/style.css" 에 인쇄 규칙이 포함되어 있습니다.
- 편집 크롬(툴바·핸들·오버레이·속성 패널)은 찍히지 않습니다
- 표의 행이 페이지 중간에서 잘리지 않습니다(
break-inside: avoid) - 셀 배경·테두리가 보존됩니다(
print-color-adjust: exact) - 표가 인쇄 폭에 맞춰 줄어듭니다
- 페이지를 직접 나누려면
pageBreak블록을 넣습니다
한계: 여러 장으로 넘어가는 표의 머리행은 2장부터 반복되지 않습니다. 그러려면 표를
<thead>로 그려야 하는데, 셀 좌표·선택·병합 코드가 모두<tbody>를 전제하고 있어 별도 작업입니다.
디자인 토큰 (--lumir-*)
0.13.0부터 편집기의 색·라운드·그림자·글꼴은 :root 에 선언된 토큰에서 나옵니다.
호스트가 같은 이름을 뒤에 선언하면 편집기 전체가 따라갑니다.
툴바·팝업·토스트는
body직속 오버레이라.lumir-editor스코프로는 안 닿습니다 —:root에 덮으세요.
| 토큰 | 기본값 | 무엇 |
| --- | --- | --- |
| --lumir-purple | #8E51FF | 강조색 하나 — 선택·포커스 링·활성 표시 |
| --lumir-purple-rgb | 142, 81, 255 | 〃 알파 오버레이용 3값. 강조색을 바꾸면 둘 다 바꿉니다(-danger-rgb·-blue-rgb 도 같은 규칙) |
| --lumir-active-bg / --lumir-active-fg | #F3DFFF / #64117E | 눌린 버튼·선택된 메뉴 항목(짝으로 씁니다) |
| --lumir-ink · -ink-2 · -ink-3 · -ink-4 | #0C1E33 #495D72 #6A7282 #818181 | 글자 4단 |
| --lumir-paper · -paper-2 · -paper-3 | #FFFFFF #F9FAFB #ECEEF3 | 면 3단 |
| --lumir-line / --lumir-line-2 | #CED8E5 / #ECEEF3 | 편집기 UI 테두리 강/약 — 툴바·팝업·버튼 |
| --lumir-grid / --lumir-grid-strong | #E5E7EB / #E5E7EB다크 #454545 / #555555 | 면 위에 그리는 선 — 표 격자와 바깥 테두리, 코드블록, 인라인 code, 읽기전용 파일카드, 양식 패널 구분선. 라이트는 안·바깥이 한 값입니다(다크는 아직 약/강으로 갈립니다). UI 선(--lumir-line*)과는 역할이 달라 값을 따로 둡니다 |
| --lumir-danger · -success · -cyan · -blue | #EB5757 #04B58B #029FE7 #2B7FFF | 상태색 |
| --lumir-r-tag · -r-btn · -r-card | 6px 10px 14.5px | 라운드 3단 |
| --lumir-shadow-card / --lumir-shadow-pop | — | 그림자 2종(카드 / 팝업) |
| --lumir-font-kr / --lumir-font-meta | Pretendard 스택 / Inter 스택 | 본문 / 숫자·메타 |
| --lumir-control-h · --lumir-icon | 30px · 16px | 컨트롤 높이 · 아이콘 |
| --lumir-indent | 1.7em | 들여쓰기 한 단의 폭(≈23.8px · 공백 약 6.8칸). em 이라 글꼴이 바뀌어도 칸수가 유지됩니다. 계층(Tab)과 첫 줄 공백(Ctrl+])이 같은 값을 씁니다 — 호스트가 바꾸면 둘 다 따라갑니다 |
문서의 색은 토큰이 아닙니다 — 글자·배경 팔레트는 저장 값에서 나옵니다 (
TEXT_COLORS/BACKGROUND_COLORS). 토큰을 바꿔도 문서 색은 그대로입니다.
호스트가 세우는 CSS 변수
| 변수 | 기본값 | 용도 |
| --- | --- | --- |
| --lumir-column-divider-color | --lumir-line(#CED8E5) | 2단 컬럼 세로 구분선 색 |
| --lumir-column-grip-space | 28px | 컬럼 구분선 양쪽 여백 |
| --lumir-inactive-selection | — | blur 상태 선택 하이라이트 색 |
| --lumir-cell-font-size | — | 표 셀 글자 크기. tables.cellFontSize 가 세운다(직접 세우지 마세요) |
| --lumir-cell-line-height | 1.35 | 〃 줄 간격 |
| --lumir-thead-bg | --lumir-paper-2(#F9FAFB) | 머리행 바탕 — 머리열과 같은 면입니다 |
| --lumir-thead-fg | --lumir-ink(#0C1E33) | 머리행 글자 |
| --lumir-thead-rule | transparent(꺼짐) | 머리행 아래 굵은 구분선. 기본은 그리지 않는다 — 색을 주면 2px 로 그려진다 |
| --lumir-thcol-bg | --lumir-paper-2(#F9FAFB) | 머리열 바탕 |
트러블슈팅
필수 체크리스트
- [ ] CSS 임포트:
import "@lumir-company/editor/style.css"; - [ ] 컨테이너에 높이 지정(부모 요소)
- [ ] Next.js:
dynamic(..., { ssr: false })사용 - [ ] React 사용 시 버전 ≥ 18
에디터가 보이지 않음 → CSS 임포트 누락 또는 컨테이너 높이 미지정.
Next.js hydration 오류 → dynamic으로 ssr: false 처리(위 Next.js 예시).
이미지 업로드 실패 → uploadFile 또는 s3Upload 중 하나는 반드시 설정.
여러 이미지 업로드 시 파일명 중복 → s3Upload.appendUUID: true.
0.12.x → 0.13.0 마이그레이션
저장된 문서는 손대지 않습니다 — 데이터 변환도, 스크립트도 없습니다. 편집기 UI 전면 개편이라 화면과 공개 색 배열이 달라집니다. 호스트가 확인할 것은 넷입니다.
| 확인할 것 | 무엇이 달라지나 | 조치 |
| --- | --- | --- |
| TEXT_COLORS/BACKGROUND_COLORS 로 자기 색 UI 를 그리는가 | 배경 14 → 12색, 값·순서·hex 가 모두 바뀝니다 | 배열을 다시 읽어 그리세요 |
| 편집기를 16px 페이지에 얹고 있는가 | .lumir-editor 가 font-size: 14px·line-height: 1.7 을 스스로 세웁니다(종전 상속) | .lumir-editor { font-size: inherit; line-height: 1.6; } |
| allowFileUpload: false 로 업로드를 막고 있는가 | 막힌 적이 없습니다. 이제 콘솔 경고가 뜹니다 | uploadFile/s3Upload 를 주지 않습니다 |
| 표 셀 글자 크기를 CSS 로 덮고 있는가 | 셀 기본이 12.5px/1.5/패딩 9/10 로 명시됐습니다 | tables.cellFontSize 옵션을 쓰세요 |
색 어휘 변화
- 없어진 값 4개 —
brown·blue-strong·yellow-strong·green-strong - 새 값 2개 —
cyan(하늘) ·plum(진보라) yellow가 배경 전용이 됩니다(TEXT_COLORS에서 빠집니다).gray-strong(진한 회색)은 배경 팔레트에 남습니다- hex 는 전부 바뀌고 배경은 대부분
rgba(알파) 입니다 — 알파를 못 받는 곳으로 내보낼 때 그대로 나가지 않습니다
없어진 값이 문서에 있어도 그대로 그려집니다.
brown·글자yellow는 값을 보존하고 표시색만 살립니다(새로 고를 수는 없습니다).*-strong셋은 읽을 때 연한 단계로 바뀝니다. 모르는 값은 지우지 않습니다 — 호스트가 심은 값을 편집기가 버리지 않습니다.
엑셀·스프레드시트 붙여넣기의 임의색 매핑도 바뀝니다 — 색상환 360도 중 115도가 다른 팔레트
색으로 갑니다(예: hue 183~207 이 blue → cyan). 호스트가 할 일은 없고, 붙여넣기 결과만
저번과 달라 보입니다.
알려진 한계
- 정본 팔레트의 글자색 셋은 흰 바탕에서 대비가 3 미만입니다(green
#04B58B2.6 · orange#FF65512.9 · cyan#029FE72.95). 본문 크기 글자에 쓰면 읽기 어렵습니다 — 강조가 목적이면 배경색 쪽을 권합니다. - 다크 테마는 이번 개편의 범위 밖입니다(크롬은 새 팔레트, 본문은 옛 규칙).
- 지원 브라우저는 Chromium 계열(Chrome·Edge)입니다. Safari/WebKit 은 검증하지 않습니다.
0.4.x → 0.5.0 마이그레이션
대부분 코드 변경 없이 버전만 올리면 됩니다. import 경로·컴포넌트·props·타입(LumirEditorProps/DefaultPartialBlock)·s3Upload 계약이 동일합니다. package.json에서 버전을 ^0.5.0으로 올리세요. (엔진 교체가 파괴적이지 않도록 마이너 버전으로 옵트인)
제거된 기능 — 사용처 정리 필요:
| 제거 항목 | 조치 |
| --- | --- |
| linkPreview prop | 제거. URL 붙여넣기는 이제 항상 인라인 링크로 처리됩니다 |
| @lumir-company/editor/api/link-preview 서브패스 | 제거. 해당 API 라우트/import 삭제 |
| htmlPreview 블록 · HtmlPreviewBlock export | 제거. 저장 JSON에 남아 있어도 로드는 무에러(알 수 없는 블록은 안전하게 폴백) |
| 독립 FloatingMenu 컴포넌트 | <LumirEditor floatingMenu floatingMenuPosition="sticky|fixed" /> prop으로 대체(기존 export는 null 렌더 스텁으로 유지) |
| 독립 FontSizeButton 컴포넌트 | 포매팅 툴바에 내장(기존 export는 null 렌더 스텁) |
위 제거 항목을 참조하지 않는 프로젝트라면 별도 조치 없이 그대로 동작합니다.
변경 이력
적는 것: 쓰는 쪽에 달라지는 것만. 안 적는 것: 내부 구조·판정·연구 문서·테스트 하네스
(lumir-editor-test). 판단 근거·구현 경위·실측값은 커밋 메시지에 있습니다.
작성 규칙 — 이 절 자체가 그 규칙의 예시입니다.
- 분류는 넷이고 순서가 고정입니다: 변경(호스트가 확인할 것) · 신규 · 버그 · 제거
- 한 항목 한 줄(넘치면 두 줄). 형식은
**무엇이 달라졌다** — 종전 → 지금 - 호스트가 코드·CSS 를 손봐야 할 수 있으면
⚠, 저장 문서가 안 바뀌면(문서 무영향) - 한 사안은 한 줄로 합칩니다 — 화면·저장·내보내기가 함께 바뀌어도, 계약과 호환 주의가 같은 기능에 걸려도 한 줄입니다
- 같은 판에서 두 번 바뀐 값·동작은 최종 상태만 적습니다(중간 단계는 커밋에 있습니다)
- 그 판에 새로 들어온 기능의 개발 중 결함은 「버그」가 아닙니다 — 나간 적 없는 것은 고쳐진 것이 아닙니다. 「버그」는 앞 판을 쓰던 사람이 겪은 것만 적습니다
- 한 분류가 8항목을 넘으면 주제로 묶고, 자세한 설명은 기능 절이나
docs/로 넘깁니다 - 판 제목은
### vX.Y.Z (YYYY-MM-DD), 아직 안 나간 것은### 미배포. 각 판은 그 판이 무엇을 바꾸는지 한 줄 요약으로 시작합니다
v0.16.0 (2026-09-17)
표 칸에 그림이 들어가고, 계층 들여쓰기 계약이 실동작 관측 위에서 정리된 판입니다.
⚠ 표시한 자리는 호스트가 확인할 것이 있습니다.
변경
- ⚠
Tab/Shift+Tab이 캐럿 위치를 가리지 않습니다 — 종전에는 줄 맨 앞만 계층이고 중간·끝은 공백 2칸(nbsp)이었습니다. 그 계약은 폐기됩니다(코드블록만 유지). 들여쓸 자리가 없으면 아무 일도 일어나지 않고, 모양만 들여쓰려면Ctrl+]를 씁니다. - ⚠ 최상위 목록 항목의
Shift+Tab이 목록을 벗어나지 않습니다 — 탈출은 줄 맨 앞Backspace· 서식 툴바 · 슬래시 메뉴입니다. - ⚠ 자식이 있는 블록 끝에서
Enter→ 새 줄이 첫 자식입니다(종전 다음 형제). - ⚠ 들여쓰기 한 단이
1.15em(≈16px) →1.7em(≈24px) 입니다.Ctrl+]첫 줄 들여쓰기도 같은 토큰을 씁니다. 되돌리려면.lumir-editor { --lumir-indent: 1.15em }(문서 무영향) - ⚠ 구조가 바뀌면 그립이 숨습니다 — 종전에는 옛 좌표에 남아 엉뚱한 줄을 가리켰습니다. 다음 마우스 이동에 그 자리 블록에 다시 뜹니다.
- 목록 깊이 상한이 3단 → 10단이고 불렛 글리프는 3주기로 되풀이됩니다(표 칸 안 인라인 마커는 좁은 폭 사정으로 3단 유지).
- 내어쓰기가 뒤 형제를 자식으로 데려갑니다 — 줄 순서가 보존됩니다.
신규
- 표 칸 안 이미지 — 붙여넣기·드래그드롭한 그림이 그 칸에 심기고, 손잡이 넷(좌·우=폭 ·
하단=높이 · 우하단=비율 고정)으로 표시 크기를 정합니다(
displayWidth/displayHeight— 원본width/height를 덮지 않습니다). 표시 상한 기본 240px(--lumir-inline-img-max-w). ⚠ 클립보드 이미지 파일은uploadFile/s3Upload를 탑니다(새 호출 지점) — 웹에서 복사한 그림은 업로드 없이 들어오고data:·blob:은 문서에 남기지 않습니다. ⚠ 새 인라인 노드inlineImage는 BlockNote 표준 인라인 밖이라 구버전 소비자가 못 읽습니다(기존 원자 3종과 같은 트레이드오프). → 표 칸 안 이미지 - 이미지 블록 세로·대각 크기 조절 — 손잡이 2 → 5개(좌·우=폭 · 하단=높이 · 좌하·우하=비율 고정).
선택 prop
previewHeight가 없으면 종전 렌더와 같습니다. ⚠ 높이를 쓰면 HTML 내보내기에<img height>가 함께 나갑니다 — 산출 HTML 을 비교하는 호스트가 걸립니다. - 블록 이동이 깊이를 바꿉니다 — 그립으로 중첩된 줄 자리에 놓으면 그 깊이를 받고, 새 단축키
Ctrl/Cmd+Shift+↑/↓로 한 칸씩 옮깁니다(딸린 줄이 함께 갑니다). - 자동서식 취소 —
-로 글머리 기호가 된 직후Backspace한 번이 그것을 무르고-를 되살립니다. 그 트리거는 그 줄과 이어지는 줄에서 다시 걸리지 않습니다. - 들여쓴 첫 자식의 줄 맨 앞
Backspace— 유일한 자식이면 내어쓰기, 형제가 있으면 부모 줄에 병합입니다(종전에는 아무 일도 일어나지 않았습니다).
버그
- 부모 줄을 지우면 딸린 줄이 전부 사라졌습니다 → 자식이 그 자리·그 깊이로 올라옵니다.
- 블록 유형이 바뀌면 중첩 자식·정렬·색·첫 줄 들여쓰기가 사라졌습니다 → 유형만 바뀝니다.
- 표 칸에 붙여넣은 그림이 저장에서 소리 없이 사라졌습니다 →
inlineImage로 회수합니다. - 문서를 훑어 고르면 표에는 선택 표시가 없는데
Delete로는 지워졌습니다 → 칠하는 대상을 지우는 대상에 맞췄습니다(새 클래스lumir-cross-sel-block). - 중첩된 블록(목록·토글) 옆에서 그립이 최상위 조상에 고정됐습니다 → 제 줄을 찾습니다.
- 내보낸 HTML 의 중첩 들여쓰기 폭이 화면과 달랐습니다 → 같은 토큰(≈24px)을 씁니다.
v0.15.0 (2026-09-16)
표를 쓰는 사람이 직접 정하는 손잡이 둘이 생기고, 행 높이가 사라지던 자리를 막은 판입니다. 표 색과 툴바 아이콘도 함께 바뀝니다 — 아래 변경 넷은 호스트가 확인할 것이 있습니다.
변경
- 표 머리행 바탕과 격자선 색이 바뀝니다 — 머리행
#ECEEF3→#F9FAFB로 머리열과 같은 면이 되고, 격자선은#AFB8C3/#A5ADB7두 값에서#E5E7EB한 값으로 갑니다(안쪽·바깥 이원화 폐기). 되돌리려면 호스트가--lumir-thead-bg·--lumir-grid·--lumir-grid-strong를 덮습니다 → 디자인 토큰. 다크 값은 그대로입니다. - 내보낸 HTML 의 테두리 hex 가 바뀝니다 — 표·코드블록이
#AFB8C3에서#E5E7EB로 갑니다. 산출 HTML 을 비교하는 호스트는 여기서 먼저 걸립니다(0.14.0 에서 한 번 바뀐 그 자리입니다). - 격자선이 옅어집니다 — 흰 바탕·머리행에서는 보이지만 색을 칠한 칸 위에서는 흐립니다. 진하게 써야 하는 화면이라면 호스트가 격자선 토큰을 덮습니다.
- 표 툴바 아이콘이 바뀝니다 — 표면 앵커 14개 중 9개가 같은 3×3 격자라 16px 에서 안 갈렸습니다. 축(구조 · 서식 · 내용)마다 다른 은유로 가릅니다. 동작과 위치는 그대로입니다.
버그
굵은 테두리가 색 칠한 칸 옆에서 1px 얇아 보이던 것 — 3px 로 칠해도 머리행·합계행처럼 배경이 있는 줄에서만 2px 로 보였습니다. 선은 칸 경계를 걸쳐 그려지는데, 이웃 칸에 들어간 1px 을 그 칸의 배경이 덮고 있었습니다.
맨 윗줄 양끝 칸만 테두리가 한 겹 더 두껍던 것 — 둥근 모서리의 곡선 획을 선과 같은 층에 겹쳐 그려, 직선부가 두 번 그려졌습니다(1px → 2px, 3px → 4px).
늘려 둔 표가 다시 열면 줄어들던 것 — 행 높이가 셀마다 저장돼, 모든 열이 세로병합으로 덮인 행은 값을 실을 칸이 없었습니다. 화면은 즉시 커지니 그 세션에선 멀쩡하고 다시 열면 내용 높이로 돌아갔습니다. 행 높이를 표 속성
content.rowHeights[]로 올렸습니다(물리 행 인덱스, 열 너비columnWidths와 대칭).- 저장 JSON 이 늘어나는 건 높이를 정한 표뿐입니다 — 안 정한 표는 키가 안 생깁니다.
- 셀
rowHeight도 계속 함께 실립니다(옛 판 호환 · 칸 단위 복사·붙여넣기). - 이미 저장된 문서는 한 글자도 안 바뀝니다 — 열었다 저장해도 그대로입니다.
내보낸 HTML 을 다시 붙여넣으면 행 높이만 사라지던 것 —
<tr style="height">를 내보내면서 읽지는 않았습니다(같은 표의 열 너비는 살아났습니다). 엑셀·워드의<tr height>도 함께 받습니다.
신규
표 행 높이 — 표 툴바의 「행 높이」 에서 보통 · 좁게 · 촘촘히 를 고릅니다. 셀 여백
9/10→6/8→4/6, 셀 줄 간격1.5→1.4→1.3→ 표 행 높이.- 안 고른 표는 렌더가 한 픽셀도 바뀌지 않습니다 — 「보통」은 값을 지웁니다.
- 저장 JSON 은 표 블록 prop
rowDensity가 하나 늘었습니다. 문서에 남으므로 결재자가 열어도 같은 자리에서 페이지가 끊기고, 내보낸 HTML 과 인쇄도 화면과 같습니다. - 끌어 정해 둔 행 높이가 있으면 밀도를 고를 때 지워집니다(마지막에 한 동작이 이깁니다).
행 높이는 「최소 높이」라 안 지우면 밀도가 먹지 않습니다.
Ctrl+Z로 되돌립니다. tables={{ rowDensity: false }}로 도구를 끕니다. 호스트 옵션tables.density와는 다른 축이고, 표마다 정한 값이 그쪽을 이깁니다.
표 셀 테두리 — 칸의 테두리를 굵기(1·2·3px) · 선종(실선·파선·점선) · 색(기본·먹·회색· 빨강·파랑·투명)으로 지정합니다. 적용 대상은 모든 · 바깥 · 안쪽 · 위 · 아래 · 왼쪽 · 오른쪽 · 없음 여덟 가지 → 셀 테두리.
- 안 쓰면 렌더가 한 픽셀도 바뀌지 않습니다 — 지정한 변에만 속성이 섭니다.
- 투명은 「없음」과 다릅니다 — 자리를 지키고 선만 감춥니다(「없음」은 그 행이 1px 줄어듭니다).
- 저장 JSON 은 셀 prop
borderTop/borderRight/borderBottom/borderLeft넷이 늘었습니다. 한 경계는 한 칸만 그립니다(위·왼쪽은 표 가장자리 칸에서만). - 내보낸 HTML 도 화면과 같습니다. 테두리를 쓴 표만
separate로 나갑니다. tables={{ cellBorder: false }}로 도구를 끕니다.
글꼴에 Noto Sans KR — 목록이 11종이 됩니다(기본 · Pretendard · Noto Sans KR · 맑은 고딕 · 굴림 · 돋움 · 바탕 · 궁서 · Arial · Times New Roman · Courier New). 붙여넣은 문서의
Noto Sans KR·Noto Sans CJK KR·본고딕표기도 이 글꼴로 스냅합니다.- 웹폰트는 패키지에 실리지 않습니다(Pretendard 와 같습니다) — 호스트 앱이 로드한 환경에서만 실제로 그려지고, 아니면 스택의 다음 후보로 폴백합니다.
- 본문 기본 글꼴은 그대로입니다 — 고른 글자에만 걸립니다.
v0.14.3 (2026-09-14)
여러 줄을 잡았을 때 툴바에서 사라지던 블록 도구를 되살린 판입니다.
신규
- 표에서 칸을 여러 개 잡고
Delete/Backspace로 한 번에 비웁니다 — 종전에는 아무 일도 일어나지 않아 칸마다 들어가 지워야 했습니다. 비우는 것은 내용뿐이고 칸·병합·배경색·정렬은 그대로 남습니다(구조를 지우는 것은 표 툴바의 행/열 삭제입니다). 되돌리기 한 번으로 전부 돌아옵니다. 읽기전용·양식fill에서는 동작하지 않습니다.
변경
- 2행 이상 선택에서도 블록 유형·정렬·들여쓰기를 쓸 수 있습니다 — 종전에는 여러 줄을 잡으면 그 도구들이 툴바에서 사라지고 빈자리만 남았습니다(설계상 v1 제한). 이제 선택된 블록 전부에 걸립니다. 계층 들여쓰기는 위에서부터, 내어쓰기는 아래에서부터 처리해 줄 순서가 보존됩니다. 블록 유형을 바꾸면 요소를 새로 만들므로 선택은 접힙니다(작업은 그대로 적용됩니다).
- 링크는 단일 블록 전용으로 남습니다 — 블록을 넘는 링크는 저장 모델에 없습니다.
- 2행 이상 선택하면 글자 크기 칸이 비던 것을 고칩니다 — 여러 줄을 잡으면 툴바의 크기 표시가 비어 있었습니다. 크기를 명시한 적 없는 문단들은 읽을 값이 없다고 보고 비워 뒀기 때문인데, 실제로 그려지는 크기는 CSS 가 정합니다. 이제 선택 전체가 한 크기면 그 값을 보여 줍니다. 여러 크기가 섞였을 때는 그대로 비우되 칸에 「혼합」이라고 적습니다 — 종전에는 빈칸만 남아 「표기가 안 나온다」로 읽혔습니다.
v0.14.2 (2026-09-14)
종속관계 없는 들여쓰기와, 표·페이지 나누기 밑에서 줄이 사라지던 결함을 고친 판입니다.
신규
Ctrl+]/Ctrl+[= 공백 들여쓰기 — 계층(종속관계)을 만들지 않고 첫 줄만 들여씁니다 (구글 독스와 같은 키). 앞에 받아 줄 블록이 없는 페이지 첫 줄에서도 됩니다 — 앞 장에서 이어지는 문단을 같은 자리에서 시작할 수 있습니다. 한 단은 계층 한 단과 같은 폭(16px)이라 두 방식으로 들여쓴 줄이 같은 자리에 섭니다. 저장·되돌리기·HTML 내보내기·인쇄에 그대로 실립니다.- 서식 툴바의 들여쓰기 버튼이 넷으로 갈립니다 — 계층 들여쓰기/내어쓰기 + 공백 들여쓰기/내어쓰기. 아이콘이 다르고(공백 쪽은 첫 줄만 밀린 모양), 툴팁에 이름·단축키와 「무엇이 다른지」가 적힙니다.
변경
- 표·페이지 나누기·그림·구분선 밑 줄에서
Tab을 눌러도 그 줄이 사라지지 않습니다 — 종전에는 앞 형제가 블록이기만 하면 그 안으로 넣었는데, 그 블록들은 자식을 버리므로 저장 문서에서 줄이 없어졌습니다(되돌리기로만 복구). 이제 자식이 뜻을 갖는 블록(문단·제목·인용·목록) 밑으로만 들어갑니다. 이미 그렇게 저장된 문서는 그대로 그려집니다 — 막는 것은 새로 만드는 것뿐입니다.
확인할 것
- 호스트가
Ctrl+]/Ctrl+[를 자기 단축키로 쓰고 있다면 겹칩니다. 편집기는 값이 실제로 바뀔 때만 키를 삼키므로(상한이거나 대상이 아니면 그대로 흘려보냅니다) 대부분은 문제가 없지만, 글 위에서 그 키를 쓰는 호스트라면 확인이 필요합니다.
v0.14.1 (2026-09-11)
읽기전용 화면에서 안내 배너를 뺀 판입니다. 0.14.0 바로 뒤에 나갑니다.
변경
- 읽기전용 배너를 뺍니다 —
editable: false에서 편집기 맨 위에 붙던 안내 줄 (「읽기전용 문서예요…」)이 없어집니다. 읽기전용이라는 사실은 호스트 화면이 이미 말하고 있어, 결재 열람처럼 그 자리에 또 적을 이유가 없는 화면에서 군더더기였습니다. 로케일 키readonlyNotice도 함께 사라집니다 — 안내가 필요하면 호스트가 편집기 밖에 그립니다.
확인할 것
- 읽기전용에서 「왜 안 써지는지」를 화면에 적어야 한다면 호스트가 편집기 밖에 그립니다 —
권한·요청 경로를 아는 자리가 그쪽입니다. 편집기는
lumir-readonly클래스만 남기므로.lumir-readonly를 보고 호스트가 자기 안내를 띄울 수 있습니다. - 로케일을 직접 넘기는 호스트는
readonlyNotice키를 지워도 되고, 남겨 둬도 무시됩니다.
v0.14.0 (2026-09-11)
워드 문서 붙여넣기와 표 격자선을 고친 판입니다. 저장 데이터 형식은 바뀌지 않습니다. 다만 아래 넷은 호스트가 확인할 것이 있습니다.
- 붙여넣기 결과의 모양이 바뀝니다 — 워드에서 붙여넣은 문서가 더는
children으로 중첩되지 않고, 글꼴(fontFamily)도 실리지 않습니다. 붙여넣기 결과 JSON 을 스냅샷으로 비교하는 판정이 있으면 그 기준선을 갱신해야 합니다. - 내보낸 HTML 의 테두리 hex 가 바뀝니다 — 표·코드블록이
#ECEEF3·#CED8E5에서#AFB8C3으로 갑니다. 산출 HTML 을 비교하는 호스트는 여기서 먼저 걸립니다. - 줄 맨 앞
Tab의 뜻이 바뀝니다 — 공백 2칸이 아니라 계층 들여쓰기입니다. 글 중간·끝의Tab과 코드블록은 종전대로입니다. - 되돌리기 스텝이 200 으로 제한됩니다 — 그보다 오래된 스텝은 밀려 사라집니다.
신규
- 줄 맨 앞
Tab/Shift+Tab= 계층 들여쓰기 — 문단·제목도 부모자식으로 중첩됩니다(노션과 같은 키). 종전에는 목록 항목만이었고 문단·제목은 서식 툴바 버튼밖에 없었습니다.4. > 4.1 > 4.1.1같은 계층을 키로 만듭니다. 깊이 상한은 목록(3단)에만 남고, 번호는 글자로 적습니다 — 자동 다단계 번호는 없습니다. - 서식 툴바에 복사·잘라내기·붙여넣기·되돌리기 버튼 — 여러 줄 선택은 브라우저의 선택이 아니라 우클릭 메뉴의 복사가 회색이었습니다. 버튼으로 그 길을 냅니다 → 여러 줄 선택.
변경
- 표 격자선이 진해집니다 — 종전에는 머리행과 회색 칠한 셀 위에서 격자가 배경과 같은 값
이라 보이지 않았습니다(대비 1.00:1). 면과 선이 한 토큰을 겸하던 것을 갈라
--lumir-grid(#AFB8C3) /--lumir-grid-strong(#A5ADB7) 를 신설했고, 표 격자·바깥 테두리와 코드블록·인라인code·읽기전용 파일카드·양식 패널 구분선이 그리로 옮겼습니다. 내보낸 HTML 의 표·코드 테두리도 같은 값입니다. 툴바·팝업 등 편집기 UI 선(--lumir-line*)은 그대로입니다. - 블록을 옮기려다 2단 컬럼이 생기지 않습니다 — 2단 변환은 커서가 블록 위를 지나 좌·우 가장자리로 들어갔을 때만입니다. 종전에는 판정에 하한이 없어 블록 왼쪽 바깥(그립이 놓이는 거터)도 「왼쪽 가장자리」로 읽혀, 그립을 잡고 세로로만 끌어도 2단이 만들어졌습니다.
- 되돌리기가 이미지·영상을 다시 불러오지 않습니다 — 되돌리기·문서 교체는 DOM 을 다시
만드는데, 같은 블록·같은
url이면 미디어 노드를 물려줍니다. 깜빡임이 사라지고 재생 위치도 유지됩니다.resolveFileUrl은 물려받은 노드에 다시 걸리지 않습니다(매번 새 presigned URL 을 주는 호스트에서 실제 재요청이 일어나던 자리입니다). - 워드 붙여넣기가 글씨체를 가져오지 않습니다 — 편집기 기본 글꼴로 통일됩니다(표 셀 포함).
종전에는 워드의
font-family를 화이트리스트 토큰으로 스냅해 받아, 붙인 줄만 다른 서체로 보였습니다. 글자 크기는 그대로입니다. 편집기 내부 복사(data-font-family)와 호스트의 HTML 불러오기는 종전대로 글꼴을 유지합니다 — 떼어내는 것은 붙여넣기 경로뿐입니다. - 워드 붙여넣기가 평탄해집니다 — 들여쓰기로 블록 계층을 만들지 않습니다. 한 줄이 한 블록이고
전부 같은 깊이입니다. 종전에는
margin-left·text-indent·mso-list level·선두 공백을 중첩 레벨로 환산했는데, 번호를 사람이 손으로 적은 절차서에서는 같은 급의 줄도 들여쓰기 폭이 제각각이라 형제여야 할 줄들이 서로의 자식이 되고 부모를 지우면 자식이 함께 사라졌습니다. 계층은 줄 맨 앞Tab으로 만듭니다.<ul>/<ol>마크업의 중첩과 첫 줄 들여쓰기 보존은 그대로입니다. - 표 머리행 아래 굵은 구분선을 뺍니다 — 머리행은 바탕색과 굵기로 이미 갈리는데, 그 위 2px
는 표 안에서 유일하게 굵은 선이라 격자보다 먼저 읽혔습니다. 셀 아래 1px 격자선은 그대로이니
경계가 사라지는 것은 아닙니다. 되돌리려면
.lumir-editor { --lumir-thead-rule: #A5ADB7 }. - 편집 한 번에 문서 직렬화가 한 번입니다 — 종전에는 히스토리 커밋과 변경 알림이 각자
getDocument()를 불러 두 번 훑었습니다. 실측(320블록·표 40개 문서, Chromium): 동작당 5.8ms → 3.6ms. 호스트onContentChange가 받는 문서는 그대로이고, 히스토리는 문자열 스냅샷을 들고 있어 호스트가 그 객체를 변형해도 되돌리기가 오염되지 않습니다. - 되돌리기 스텝에 상한이 생겼습니다(200) — 타이핑 한 글자가 1스텝이라 상한이 없으면 문서 전체 복사본이 무한히 쌓였습니다. 상한을 넘으면 가장 오래된 스텝부터 밀립니다.
- 줄 맨 앞
Tab은 공백을 넣지 않습니다 — 계층 들여쓰기로 갑니다. 글 중간·끝의Tab은 종전대로 공백 2칸이고, 코드블록은 어디서나 공백입니다. - 중첩 들여쓰기 폭이 토큰이 됩니다 —
--lumir-indent(1.15em≈ 16px). 종전 고정값24px에서 좁아집니다. 호스트가 값을 정할 수 있습니다:.lumir-editor { --lumir-indent: 24px }.
v0.13.0 (2026-09-10)
편집기 UI 전면 개편판입니다. 디자인 정본에 맞춰 토큰·툴바·메뉴·블록·표·양식을 다시 그렸습니다. 저장 데이터 형식은 바뀌지 않습니다 — 호스트가 확인할 것은 0.12.x → 0.13.0 마이그레이션에 모았습니다.
신규
- 글머리·번호 목록 3단 중첩 — 항목 시작 캐럿에서
Tab/Shift+Tab, 최상위에서 내어쓰면 문단이 됩니다. 글 중간의Tab은 종전대로 공백 2칸입니다. 표 셀 안 목록과 같은 계약이고, 번호는 단마다 다시 매겨집니다. - 디자인 토큰 공개 — 색·라운드·그림자·글꼴이
:root의--lumir-*변수에서 나옵니다. 호스트가 덮어쓰면 편집기 전체가 따라갑니다(툴바·팝업은body직속이라:root에 덮습니다). - 빈 이미지·비디오·양식 블록이 드롭존입니다 — 「클릭하거나 파일을 끌어다 놓으세요」 한 줄
