npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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 라운드트립 호환.

npm version License: MIT

구 BlockNote 기반 @lumir-company/[email protected]드롭인 대체입니다. 동일한 API 표면(LumirEditor 컴포넌트·props·타입·s3Upload 계약)을 유지하되, 코어에서 React / BlockNote / ProseMirror 런타임을 완전히 제거했습니다. 코어는 react-free라 서버·명령형 환경에서도 사용할 수 있고, React 컴포넌트는 선택적 래퍼로 제공됩니다.


목차


특징

| 특징 | 설명 | | --- | --- | | 의존성 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/editor

Peer dependencies (선택):

  • react ≥ 18.0.0
  • react-dom ≥ 18.0.0

React/react-domReact 컴포넌트(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) · 사이드/플로팅 메뉴로 삽입됩니다. 업로드 방식은 아래 우선순위로 결정됩니다.

  1. uploadFile prop이 있으면 → 해당 함수로 업로드
  2. 없고 s3Upload가 있으면 → S3 presigned URL 업로드
  3. 둘 다 없으면 → 파일 삽입 시 업로드 실패

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.pnguser123_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}%`),
  }}
/>
  • onProgressS3 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-* 를 그대로 씁니다 — 그쪽에서는 칸 안쪽으로 자라고 점 간격도 브라우저가 칸마다 맞춥니다. 대신 배경을 지우는 소비처(메일 클라이언트 등)에서도 선이 사라지지 않습니다.
  • 인쇄는 화면 그대로 따라갑니다.

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" }} />

filleditable={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를 주면 경고 후 rolelockMode로 옮겨 처리합니다(authordesign). 잠금이 조용히 풀리는 경로는 없습니다.

formMode.fieldKeysonFieldChange는 아무 동작도 하지 않습니다(경고만).

이미 저장된 문서는 그대로 돕니다. 필드 원자는 왕복하고, 아래 순수 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).
    • 전역: columnDivider prop(기본 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-editorfont-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을 로드할 때 크래시합니다. 형제 키 방식은 구버전에서 조용히 무시되어(글자 크기만 미표시) 안전하게 로드됩니다.

  • 에디터 로드/저장 시 변환은 자동입니다(initialContentonContentChange).
  • 외부 렌더러에서 저장 JSON을 직접 다룬다면, 공개 export된 liftFontSize(blocks)로 형제 키를 styles.fontSize로 복원한 뒤 사용하세요. 반대로 외부로 내보낼 때는 반드시 lowerFontSize(blocks)를 거쳐야 합니다(styles.fontSize가 유출되면 구버전 소비 앱이 크래시).
  • 직렬화 형태 타입은 SerializedStyledText로 export됩니다.

링크

URL을 텍스트에 붙여넣으면 자동으로 인라인 링크로 변환됩니다(구버전의 자동 OG 카드 생성은 제거됨).

  • 링크 툴바(Notion식): 링크에 hover하면 URL 툴팁이 뜨고, 클릭하면 URL·텍스트를 편집하는 popup이 열립니다.
  • 보안: javascript:·data:·vbscript:·file: 등 위험 프로토콜은 차단됩니다.
  • linkToolbar prop(기본 true)으로 링크 툴바 표시를 제어합니다.

구버전의 linkPreview prop과 /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 | 봉인rolelockMode로 매핑됩니다(경고) | | 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-editorfont-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 이 bluecyan). 호스트가 할 일은 없고, 붙여넣기 결과만 저번과 달라 보입니다.

알려진 한계

  • 정본 팔레트의 글자색 셋은 흰 바탕에서 대비가 3 미만입니다(green #04B58B 2.6 · orange #FF6551 2.9 · cyan #029FE7 2.95). 본문 크기 글자에 쓰면 읽기 어렵습니다 — 강조가 목적이면 배경색 쪽을 권합니다.
  • 다크 테마는 이번 개편의 범위 밖입니다(크롬은 새 팔레트, 본문은 옛 규칙).
  • 지원 브라우저는 Chromium 계열(Chrome·Edge)입니다. Safari/WebKit 은 검증하지 않습니다.

0.4.x → 0.5.0 마이그레이션

대부분 코드 변경 없이 버전만 올리면 됩니다. import 경로·컴포넌트·props·타입(LumirEditorProps/DefaultPartialBlocks3Upload 계약이 동일합니다. 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). 판단 근거·구현 경위·실측값은 커밋 메시지에 있습니다.

작성 규칙 — 이 절 자체가 그 규칙의 예시입니다.

  1. 분류는 넷이고 순서가 고정입니다: 변경(호스트가 확인할 것) · 신규 · 버그 · 제거
  2. 한 항목 한 줄(넘치면 두 줄). 형식은 **무엇이 달라졌다** — 종전 → 지금
  3. 호스트가 코드·CSS 를 손봐야 할 수 있으면 , 저장 문서가 안 바뀌면 (문서 무영향)
  4. 한 사안은 한 줄로 합칩니다 — 화면·저장·내보내기가 함께 바뀌어도, 계약과 호환 주의가 같은 기능에 걸려도 한 줄입니다
  5. 같은 판에서 두 번 바뀐 값·동작은 최종 상태만 적습니다(중간 단계는 커밋에 있습니다)
  6. 그 판에 새로 들어온 기능의 개발 중 결함은 「버그」가 아닙니다 — 나간 적 없는 것은 고쳐진 것이 아닙니다. 「버그」는 앞 판을 쓰던 사람이 겪은 것만 적습니다
  7. 한 분류가 8항목을 넘으면 주제로 묶고, 자세한 설명은 기능 절이나 docs/ 로 넘깁니다
  8. 판 제목은 ### 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/106/84/6, 셀 줄 간격 1.51.41.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 에 덮습니다).
  • 빈 이미지·비디오·양식 블록이 드롭존입니다 — 「클릭하거나 파일을 끌어다 놓으세요」 한 줄