shadow-tiptap
v0.1.12
Published
Shadow DOM 기반 Tiptap React 라이브러리
Readme
shadow-tiptap
Shadow DOM 기반 Tiptap React 라이브러리입니다. 현재 런타임 공개 export 는 ShadowTiptapBase, ShadowTiptapEditor, ShadowTiptapViewer 3개입니다.
ShadowTiptapBase는 패키지 내부 Shadow DOM host로 스타일을 격리하며, 필요한 경우에만 Emotion cache를 사용할 수 있습니다. 패키지 내장 Editor/Viewer는 기본적으로 Emotion cache를 생성하지 않지만, Emotion 기반 custom block/UI를 Shadow DOM 안에서 소비해야 하면 emotionCache로 켤 수 있습니다.
설치
bun add shadow-tiptapAI 프롬프트용 사용법
아래 블록을 LLM 프롬프트나 에이전트 컨텍스트에 그대로 붙여넣어 shadow-tiptap 사용법을 전달할 수 있습니다.
shadow-tiptap — Shadow DOM 기반 Tiptap React 라이브러리
## 설치
bun add shadow-tiptap
## Peer Dependencies
react, react-dom (`^18.3.1 || ^19.0.0`)
Tiptap 런타임과 Shadow DOM host 관련 패키지는 shadow-tiptap dependency로 함께 설치됩니다.
## 공개 API
import {
ShadowTiptapBase,
ShadowTiptapEditor,
ShadowTiptapViewer,
} from 'shadow-tiptap';
### ShadowTiptapBase
Shadow DOM 경계 + 스타일 격리 래퍼.
<ShadowTiptapBase scopeId="my-scope" targetSelector="#my-scope">
<div>커스텀 콘텐츠</div>
</ShadowTiptapBase>
// Emotion 기반 UI를 직접 감쌀 때만 기본값(true)을 사용하고, 순수 CSS 기반이면 emotionCache={false}로 cache 생성을 생략할 수 있습니다.
### ShadowTiptapEditor
Props:
- content: string | object — 초기 콘텐츠
- contentType?: 'auto' | 'json' | 'markdown' | 'html'
- outputFormat?: 'json' | 'html' | 'markdown'
- onChange?: (value) => void
- placeholder?: string
- maxLength?: number
- disabled?: boolean
- imageUpload?: (file: File) => Promise<string>
- textColorToolbar?: { colors?: { label: string; value: string }[] }
- extensions?: Extensions | ((defaultExtensions) => Extensions) — 커스텀 Tiptap extension 구성. 배열은 기본 extensions를 대체하고, 함수는 기본 extensions를 받아 확장합니다.
- editorProps?: EditorOptions['editorProps'] — handlePaste, attributes 등 Tiptap editorProps 전달
- initialMode?: 'editor' | 'markdown' | 'split'
- showModeToggle?: boolean
- showToolbar?: boolean
- toolbar?: React.ReactNode | ((context) => React.ReactNode)
- markdownMinHeight?: number (px)
- markdownMaxHeight?: number (px)
- emotionCache?: boolean — 기본값 false. Emotion 기반 UI를 Editor 내부에서 렌더링해야 할 때 true
예시:
<ShadowTiptapEditor
content="# 제목"
contentType="markdown"
outputFormat="markdown"
initialMode="split"
showModeToggle
onChange={(v) => console.log(v)}
/>
### ShadowTiptapViewer
Props:
- content: string | object — 표시할 콘텐츠
- contentType?: 'auto' | 'json' | 'markdown' | 'html'
- customBlocks?: Record<string, { fields?: Record<string, 'string' | 'number' | 'boolean' | 'json'>; render: (node, ctx) => React.ReactNode }>
- styleInjection?: { scopeId, targetSelector, disableDefaultStyles?, position?: 'top'|'bottom', styles?: { id: string; cssText: string }[] }
- adaptiveStyleInjection?: { adoptedStyleSheets?: CSSStyleSheet[] }
- emotionCache?: boolean — 기본값 false. customBlocks가 Emotion 기반 컴포넌트를 렌더링해야 할 때 true
예시:
<ShadowTiptapViewer
content="**읽기 전용**"
contentType="markdown"
styleInjection={{ scopeId: 'v', targetSelector: '#v', styles: [{ id: 'c', cssText: '.shadow-tiptap-viewer { color: #111; }' }] }}
emotionCache={true} // Emotion 기반 customBlocks를 Shadow DOM 내부에서 스타일링해야 할 때만 사용
/>
## 주의사항
- SSR 환경에서는 편집기 컴포넌트를 'use client' 경계 안에 두세요.
- split 모드는 markdown textarea + viewer preview 조합입니다 (WYSIWYG + preview 아님).Storybook으로 확인하기
라이브러리의 ShadowTiptapBase, ShadowTiptapEditor, ShadowTiptapViewer를 시각적으로 확인하려면 Storybook을 실행하세요.
bun install
bun run storybook- 기본 포트:
6006 - Storybook 내부 stories 는 모두
import { ... } from 'shadow-tiptap'형식을 유지하고, 로컬 개발에서는 Storybook Vite alias가 루트src/index.ts를 바라봅니다. - 정적 산출물 확인이 필요하면
bun run build-storybook을 실행합니다.
로컬 검증
패키지 변경이나 릴리즈 전에는 아래 명령을 순서대로 확인하는 것을 권장합니다.
bun install
bun x vitest run
bun run build
bun run build-storybook
bun pm pack --dry-runbun x vitest run— 공개 API, 문서 계약, Storybook 계약, 릴리즈 준비성 테스트를 한 번에 확인합니다.bun run build— 스타일 생성(build:styles)과 타입/번들 산출물을 갱신합니다.bun pm pack --dry-run— 실제 배포에 포함될 파일 목록과dist산출물을 점검합니다.
Peer Dependencies
아래 패키지는 앱에서 직접 설치해야 합니다.
react,react-dom(^18.3.1 || ^19.0.0)
Tiptap 런타임과 Shadow DOM host 관련 패키지(@tiptap/*, shadow-react-host, @emotion/*, react-shadow)는 shadow-tiptap의 dependency로 함께 설치됩니다.
내장 ShadowTiptapEditor/ShadowTiptapViewer는 Emotion cache가 필요 없는 소비처를 위해 기본 emotionCache 값을 false로 둡니다. Emotion 기반 custom block 또는 UI를 Shadow DOM 안에서 스타일링해야 하는 소비처는 해당 컴포넌트에 emotionCache={true}를 명시해 내부 ShadowDomHost cache를 사용할 수 있습니다. ShadowTiptapBase를 직접 사용할 때는 기존 호환을 위해 기본값이 true이며, 순수 CSS 또는 adoptedStyleSheets 기반이면 emotionCache={false}를 명시할 수 있습니다.
기본 import
import {
ShadowTiptapBase,
ShadowTiptapEditor,
ShadowTiptapViewer,
} from 'shadow-tiptap';Base 예시
import { ShadowTiptapBase } from 'shadow-tiptap';
export function BaseExample() {
return (
<ShadowTiptapBase scopeId="post-editor" targetSelector="#post-editor" emotionCache={false}>
<div>커스텀 콘텐츠</div>
</ShadowTiptapBase>
);
}Editor 예시
import { ShadowTiptapEditor } from 'shadow-tiptap';
export function EditorExample() {
return (
<ShadowTiptapEditor
content="# 제목"
contentType="markdown"
outputFormat="markdown"
initialMode="split"
showModeToggle
showToolbar
toolbar={({ editor, mode, setMode }) => (
<button type="button" onClick={() => setMode(mode === 'split' ? 'editor' : 'split')}>
{editor ? '커스텀 툴바' : '준비 중'}
</button>
)}
onChange={(value) => {
console.log(value);
}}
/>
);
}Editor 입력/출력 계약
content: 초기 콘텐츠 (string | object)contentType:'auto' | 'json' | 'markdown' | 'html'outputFormat:'json' | 'html' | 'markdown'initialMode:'editor' | 'markdown' | 'split'showModeToggle: 모드 토글 버튼 노출 여부showToolbar: 기본값true. editor 상단 toolbar 영역 노출 여부toolbar: 커스텀 toolbar.ReactNode또는{ editor, mode, setMode, disabled }context를 받는 render prop을 전달할 수 있습니다.showModeToggle: 기본 toolbar 안의 모드 토글 노출 여부.toolbar를 직접 전달하면 커스텀 toolbar가 기본 모드 토글을 대체합니다.markdownMinHeight: markdown 모드 textarea 최소 높이(px)markdownMaxHeight: markdown 모드 textarea 최대 높이(px)emotionCache: 기본값false. Emotion 기반 UI를 Editor 내부에서 렌더링해야 하면true로 켭니다.extensions: 커스텀 Tiptap extension 구성입니다. 배열을 넘기면 패키지 기본 extensions를 대체하고, 함수를 넘기면 기본 extensions를 받아 추가/교체할 수 있습니다.editorProps: TiptapuseEditor의editorProps로 전달됩니다.handlePaste,handleDrop,attributes.class,aria-label같은 기존 editor 설정을 이관할 때 사용합니다.
markdown 모드 작성 UX
initialMode="markdown"이면 읽기 전용<pre>대신 수정 가능한 textarea 가 렌더됩니다.- textarea 높이는 입력량에 따라 자동 조절되며,
markdownMinHeight/markdownMaxHeight로 범위를 제어할 수 있습니다. - 동일 내용은 내부 editor JSON state 와 동기화되므로 split / editor 모드로 전환해도 수정 내용이 유지됩니다.
기존 Tiptap editor toolbar 마이그레이션
기존 앱에서 직접 useEditor와 EditorContent를 구성했다면, toolbar UI만 toolbar로 옮기는 것만으로는 부족할 수 있습니다. toolbar 버튼이 호출하는 명령을 사용하려면 기존 extension과 editorProps도 함께 전달합니다.
import { Mark, mergeAttributes } from '@tiptap/core';
import Highlight from '@tiptap/extension-highlight';
import Image from '@tiptap/extension-image';
import Link from '@tiptap/extension-link';
import Underline from '@tiptap/extension-underline';
import StarterKit from '@tiptap/starter-kit';
import { ShadowTiptapEditor } from 'shadow-tiptap';
const SemanticTextColorExtension = Mark.create({
name: 'semanticTextColor',
renderHTML({ HTMLAttributes }) {
return ['span', mergeAttributes(HTMLAttributes), 0];
},
});
<ShadowTiptapEditor
content={value}
contentType="json"
outputFormat="json"
extensions={[
StarterKit,
Image,
Link.configure({ openOnClick: false }),
Highlight,
Underline,
SemanticTextColorExtension,
]}
editorProps={{
handlePaste: (_view, event) => {
// 기존 image paste 처리 로직을 여기에 이관합니다.
return false;
},
attributes: {
'aria-label': '내용',
class: 'min-h-[260px] rounded-clay-medium border border-clay-border-default bg-white',
},
}}
toolbar={({ editor, disabled }) => (
<button
type="button"
disabled={!editor || disabled}
onClick={() => editor?.chain().focus().toggleBold().run()}
>
굵게
</button>
)}
/>split 모드 동작
initialMode="split"이면 좌측에는 markdown textarea, 우측에는ShadowTiptapViewer기반 preview 가 렌더됩니다.- 즉, split 모드는 WYSIWYG editor + preview 조합이 아니라 text editor + viewer preview 조합입니다.
Viewer 예시
import { ShadowTiptapViewer } from 'shadow-tiptap';
export function ViewerExample() {
return (
<ShadowTiptapViewer
content={'# 제목\n\n본문은 패키지 기본 typography preset으로 바로 렌더됩니다.'}
contentType="markdown"
/>
);
}viewer typography 기본값과 styleInjection
ShadowTiptapViewer는 .shadow-tiptap-viewer 셀렉터 기준 기본 typography preset을 패키지 기본값으로 함께 주입합니다.
- 기본 동작: heading, list, blockquote, code spacing 을 별도 CSS 없이 렌더합니다.
- headless/opt-out:
styleInjection.disableDefaultStyles를true로 설정합니다. - custom override:
styleInjection.styles와position: 'bottom'을 함께 사용해 consumer CSS가 기본 preset 뒤에서 덮어쓰게 합니다. - adaptive style injection: constructable stylesheet는
adaptiveStyleInjection.adoptedStyleSheets로 별도 전달할 수 있습니다.
<ShadowTiptapViewer
content="## Override example"
contentType="markdown"
styleInjection={{
scopeId: 'viewer-scope',
targetSelector: '#viewer-scope',
position: 'bottom',
styles: [
{
id: 'viewer-override',
cssText:
'#viewer-scope .shadow-tiptap-viewer { color: #111; } #viewer-scope .shadow-tiptap-viewer h2 { color: #7c3aed; }',
},
],
}}
adaptiveStyleInjection={{
adoptedStyleSheets: [viewerConstructableStyleSheet],
}}
/>custom block registry 사용
customBlocks로 노드 타입별 렌더러를 등록할 수 있습니다.
import { ShadowTiptapViewer } from 'shadow-tiptap';
const customBlocks = {
callout: {
fields: { tone: 'string' },
render: (node, context) => (
<aside data-tone={String(node.attrs?.tone ?? 'info')}>
{context.renderChildren(node.content)}
</aside>
),
},
};
export function CustomBlockExample() {
return <ShadowTiptapViewer content={{ type: 'doc', content: [] }} customBlocks={customBlocks} />;
}
export function EmotionCustomBlockExample() {
return <ShadowTiptapViewer content={{ type: 'doc', content: [] }} customBlocks={customBlocks} emotionCache />;
}customBlocks가 Emotion 기반 컴포넌트를 반환하면 emotionCache를 켜고, 순수 CSS/React 노드만 반환하면 기본값 false를 유지합니다.
SSR / client boundary 주의사항
import { ... } from 'shadow-tiptap'자체는 Node SSR 환경에서도 안전합니다.- 실제 편집 상호작용은 브라우저에서 동작하므로, Next.js 같은 환경에서는 편집기 사용 컴포넌트를 클라이언트 경계(
'use client') 안에 두세요. - 서버 렌더 시에는 wrapper 마크업이 렌더되며, 클라이언트 하이드레이션 이후 Shadow DOM/에디터 동작이 활성화됩니다.
Vite dev server / optimized dependency 설정
Magnet Shadow DOM Web Component, lazy route, side sheet처럼
shadow-tiptap이 별도 lazy boundary에서 처음 import되는 앱은 Vite dev server의 optimizeDeps 결과에서 React/Emotion이shadow-tiptap.js안으로 함께 pre-bundle될 수 있습니다. 이 경우 소비 앱 React dispatcher와 분리되어Invalid hook call이 발생할 수 있으므로 Vite 설정에서 React/Emotion singleton을 명시적으로 고정하세요.
import { defineConfig } from 'vite';
export default defineConfig({
resolve: {
dedupe: ['react', 'react-dom', '@emotion/react', '@emotion/cache'],
},
optimizeDeps: {
include: [
'react',
'react-dom',
'react-dom/server',
'react-dom/client',
'react/jsx-runtime',
'@emotion/react',
'@emotion/cache',
'react-shadow',
'react-shadow/emotion',
'shadow-react-host',
'shadow-tiptap',
],
},
});이 설정은 shadow-tiptap 배포 번들의 external 처리와 별개로, Vite dev pre-bundling 단계에서도 React/React DOM/Emotion을 앱의 단일 optimized entry로 분리하도록 강제합니다. 프로덕션 번들은 기존 peer dependency 해석을 따르며, 로컬 dev server에서 .vite/deps/shadow-tiptap.js에 ReactCurrentDispatcher 또는 You are loading @emotion/react when it is already loaded 같은 런타임 코드/경고 문자열이 직접 들어가는 회귀를 막기 위한 설정입니다.
Storybook 스토리 구성
Base/ShadowTiptapBase— Shadow DOM 경계와 기본 스타일 주입 확인Editor/ShadowTiptapEditor— editor / markdown / split mode 와 출력 직렬화 확인Viewer/ShadowTiptapViewer— markdown, custom block, empty state, styleInjection 확인
기존 앱에서 마이그레이션/대체 포인트
- 앱 내부 editor/viewer 래퍼 대신
ShadowTiptapEditor,ShadowTiptapViewer로 교체합니다. - 기존 스타일 격리 로직은
ShadowTiptapBase+styleInjection(scopeId,targetSelector)으로 대체합니다. - 뷰어 커스텀 노드 렌더 스위치문은
customBlocks레지스트리로 이동합니다. - 라이브러리 공개 API는 컴포넌트 3개와 타입 export 만 사용하고, helper 직접 import 의존은 제거합니다.
