A combination markdown editor package that the host app can directly control
chaeditor started from the task of extracting the internal markdown editor of the chaen project as an independent package.
The main focus was on how much control can be retained by the host app while extracting editing features as a package.
Existing markdown editors often leaned to one side. They were either empty enough to require direct assembly or had so many features but with a UI and operation method strongly fixed that it was difficult to integrate naturally into a service. In reality, each host wants different features, and aspects like link previews, file uploads, and image rendering need to connect directly with existing APIs.
chaeditor is designed to bundle writing experiences that felt useful in velog, Naver Blog, Discord, and Notion into a single basic implementation, allowing the host app to continue to manage UI shell, metadata, upload, and rendering responsibilities. Instead of going completely headless, it aims to be an editor that can be used immediately with defaults but can also be modified, replaced, and connected according to the existing service structure if needed.
Rather than merely adding features, we also organized the open surface and operation flow so that other apps can actually use it.
Internally, it is composed of FSD (Feature-Sliced Design) layers. By separating the writing UI, editing features, pure models, and basic primitives by layer, it became easier to determine where to cut off the open API even after package extraction.
Thanks to having this structure in place, we were able to naturally divide entry points like core, react, .
While retaining the writing features, we created a form that makes it easy to manage as a public package by separating logic without React dependencies and basic UI implementations.
Components such as upload APIs, link previews, and image renderers, which vary by service policy, have been separated as adapters to ensure they are not fixed within the package.
If an adapter is not provided, the respective toolbar action is automatically disabled. The chaeditor/default-host entry point provides a default implementation that calls , , , and includes pre-implemented image optimization before upload (1600×1600px / 84% quality).
MarkdownRenderer is designed for server rendering, the editor surface as a client component, and uploads and link previews are connected to route handler or server action side policies. When reintegrating into chaen, only the editor surface was replaced with the package, while the upload API, rendering based on , link preview, and primitive shell remained as they were on the host side.
This structure is in place to consistently apply the host app's design system throughout the editor. Slots like , , , , , and are open for the host app to replace with its own components.
Slots that are not injected fall back to the panda-primitives default implementation. MarkdownPrimitiveProvider injects them at the top of the tree, and they are consumed anywhere inside with . By replacing primitives, the editor's internal link/image/math helpers and overlay UI operate on the same design system.
Style switching points are exposed through CSS custom properties. If only Panda CSS tokens were exposed, it would be difficult for the host app to reuse in environments like Tailwind, Emotion, styled-components, and vanilla-extract, so CSS variables are set as the external contract, with Panda remaining as a basic implementation only.
Successful build alone is not sufficient. This is because the @/ alias may not be resolved post-build, type declaration files may be missing, or internal dependencies may be unintentionally removed due to treeshaking. You need to extract the actual npm tarball and verify up to the stage where a temporary consumer imports it to ensure that package exports, type declarations, and CJS/ESM surface are intact.
File: scripts/verify-package-surface.mjs
We operate only two test groups.
| Group | Criteria | workers |
|---|---|---|
node | @vitest-environment node comment at the top of the file, pure logic | Default settings |
dom-ui | *.test.tsx, React UI and DOM wiring | Default settings |
Three types of SVG mocks:
Pure markdown helpers and selection utils are sent to node tests, leaving only areas requiring DOM interaction like editor, toolbar, and viewer in dom-ui.
By linking Storybook 10 + Chromatic, we ascertain whether CSS changes extend to other surfaces via PR unit visual diffs. During version upgrades, the Storybook build and Chromatic deployment automatically follow in the version script.
Storybook is organized as a reference to compare what changes and which contracts are maintained in the editor / toolbar / renderer / embed workflow, rather than as a "component collection". README, wiki, and host preset documents were divided in the same flow. Storybook is positioned to quickly confirm visual states and interactions, while the wiki organizes integrated guides and references in both English/Korean, and host preset documentation is designed to immediately utilize actual connection examples.
chaeditor was designed to be open for use in both React apps and Next.js apps. Therefore, instead of relying on bundler-specific components for SVG icons, it adopted a method of receiving raw SVG strings, organizing them at runtime, and rendering them as inline SVGs. This was flexible in the source environment, but it caused bugs that appeared only when the dev path and dist path were different.
Immediately after connecting chaeditor to chaen, the Quote icon in the toolbar disappeared. It was normal in Storybook, but only disappeared in the actual deployment environment.
Storybook reads source files directly on the Vite dev server, while the actual host app references dist/ as an npm package. The icon component imports the SVG file as a string with ?raw and renders it into inline SVG with at runtime.
normalizeSvgMarkup included a normalization step to scale the SVG according to the icon size.
This regex was applied globally (g flag) to the entire SVG string. quote.svg included a clipPath definition exported from Figma.
The global regex removed width and from , resulting in , thus the clip area became 0×0 and the entire icon was clipped.
The SVGs with clipPaths were only quote.svg and github.svg. github.svg was also broken for the same reason, but it was used as a link icon, so it went unnoticed for a while.
The fix was to narrow the external matching to a single <svg> opening tag.
This modification also led to the addition of a regression test to handle the clipPath case.
When editing existing articles in chaen, there was a noticeable delay in UI changes even when applying just one toolbar function.
Cause 1 — execCommand Replaced Entire Document
Toolbar actions are simple tasks that apply Markdown format only to the selected area. However, the internal implementation always selected from start to finish and replaced the entire document with insertText.
document.execCommand('insertText') is a deprecated API but is the only way to register changes in the browser's native undo stack.
Pressing bold on an article with 50,000 characters causes the browser to record 50,000 characters in the undo stack and handle a DOM mutation that rewrites 50,000 characters synchronously.
We modified it to replace only the changed section by calculating the common prefix and suffix of previous and new values.
In a 50,000-character article, clicking bold now only replaces **text** for around 10 characters. The execCommand cost is reduced from O(n) → O(change amount), and undo only reverts the actual changed parts.
Cause 2 — Preview render executes synchronously with every input
When is called, the preview panel also re-renders synchronously. (scanning the entire document), (custom block parsing), and (code highlighting) were all structured to execute synchronously with every change in .
The value for preview was separated using React.useDeferredValue.
In React 19, useDeferredValue handles urgent updates like user input first and defers the preview render to a lower priority. The textarea responds immediately, and the preview catches up as React has spare time.
The key was realizing that "a disproportionately large cost was being used for a small formatting action." As documents grow longer, such issues become more apparent, so the toolbar transform and preview render paths were simplified first with a patch. Although there is room to reduce further, the critical bottleneck blocking actual editing flow was quickly addressed and deployed at this stage.
In releasing the package as open source, the main focus was on clearly defining boundaries and documentation. In an extensible structure, if the responsibilities between the package and the host app are unclear, it becomes difficult for the integrator to know what to modify. The host adapter, primitiveRegistry, and CSS variable contract all represent these boundaries in code, while the README and Storybook explain these boundaries in documentation.
Upon reattaching to chaen, we were able to verify if these boundaries were effectively maintained, and we addressed bugs and performance issues that arose during this process at the package level.
src/
├── widgets/editor/ # MarkdownEditor (top-level combination)
├── features/edit-markdown/ # toolbar / image / file / link / math / video / formatting
├── entities/editor-core/ # pure model utilities (no React)
├── shared/ # UI primitives + shared library
├── core/ # public API barrel (re-exports entities)
├── react/ # public API barrel (re-exports features + widgets)
└── panda-primitives/ # basic Panda CSS implementationtype MarkdownEditorHostAdapters = {
uploadImage?:(args: { file: File; contentType; imageKind }) => Promise<string>;
uploadFile?:(args: { file: File; contentType }) => Promise<string>;
uploadVideo?:(args: { file: File; contentType }) => Promise<VideoEmbedReference>;
fetchLinkPreviewMeta?:(url: string) => Promise<LinkPreviewMeta>;
resolveAttachmentHref?:(href: string, contentType) => Promise<string>;
renderImage?:HostImageRenderer; // Custom image components like Next.js Image
};/api/images/api/files/api/videosnext/imageButtonInputTextareaPopoverModalTooltipexport type MarkdownPrimitiveRegistry = Partial<{
Button: MarkdownButtonPrimitive; // forwardRef<HTMLButtonElement, ButtonProps>
Input: MarkdownInputPrimitive; // forwardRef<HTMLInputElement, InputProps>
Modal: MarkdownModalPrimitive; // ComponentType<ModalProps>
Popover: MarkdownPopoverPrimitive; // ComponentType<PopoverProps>
Textarea: MarkdownTextareaPrimitive; // forwardRef<HTMLTextAreaElement, TextareaProps>
Tooltip: MarkdownTooltipPrimitive; // ComponentType<TooltipProps>
}>;export const CHAEDITOR_THEME_VARIABLES = {
primary: '--chaeditor-color-primary',
primaryHover: '--chaeditor-color-primary-hover',
primaryContrast: '--chaeditor-color-primary-contrast',
primaryMuted: '--chaeditor-color-primary-muted',
primarySubtle: '--chaeditor-color-primary-subtle',
surface: '--chaeditor-color-surface',
surfaceMuted: '--chaeditor-color-surface-muted',
surfaceStrong: '--chaeditor-color-surface-strong',
text: '--chaeditor-color-text',
textSubtle: '--chaeditor-color-text-subtle',
muted: '--chaeditor-color-muted',
border: '--chaeditor-color-border',
borderStrong: '--chaeditor-color-border-strong',
focusRing: '--chaeditor-color-focus-ring',
error: '--chaeditor-color-error',
success: '--chaeditor-color-success',
overlayBackdrop: '--chaeditor-color-overlay-backdrop',
sansFont: '--chaeditor-font-sans',
sansJaFont: '--chaeditor-font-sans-ja',
monoFont: '--chaeditor-font-mono',
} as const;
```ts
import { createChaeditorThemeVars } from 'chaeditor/core';
const myTheme = createChaeditorThemeVars({
primary: '#3b82f6',
surface: '#ffffff',
text: '#18181b',
});
// → { '--chaeditor-color-primary': '#3b82f6', ... }
// → Apply with <div style={myTheme}>.tgz with npm pack/.tmp/package-surface-smoke/)core, react, default-host, panda-primitives)tsc --noEmit)*.svg → Dummy React component (svg-component.tsx)*.svg?url → Dummy URL string (svg-mock-url.ts)*.svg?raw → Inline SVG string (Vite inline plugin)// app-icons.tsx
import QuoteSvg from '@/shared/assets/icons/quote.svg?raw';
const rawSvgMarkup = resolveRawSvgMarkup(svgSource); // Fixed at module evaluation
const svgMarkup = React.useMemo(
() => scopeSvgMarkupIds(normalizeSvgMarkup(rawSvgMarkup), iconScopeId),
[assetSvgMarkup, iconScopeId],
);// Before modification
.replace(/\s(width|height)="[^"]*"/gi, '')<g clip-path="url(#clip0_403_3386)">
<path d="M0 7.99939V13.9994..." fill="currentColor"/>
<path d="M18.0002 3.99939..." fill="currentColor"/>
</g>
<defs>
<clipPath id="clip0_403_3386">
<rect width="24" height="24" fill="white"/> ← This was the problem
</clipPath>
</defs><rect width="24" height="24"><rect/>// After modification
.replace(/<svg\b[^>]*>/, svgTag => svgTag.replace(/\s(width|height)="[^"]*"/gi, ''))it('Under raw SVG markup with a clipPath, normalizeSvgMarkup must preserve clipPath element dimensions', () => {
const input = [
'<svg width="24" height="24" viewBox="0 0 24 24">',
'<g clip-path="url(#clip0)"><path fill="currentColor" /></g>',
'<defs><clipPath id="clip0"><rect width="24" height="24" fill="white"/></clipPath></defs>',
'</svg>',
].join('');
const result = normalizeSvgMarkup(input);
expect(result).toContain('width="24" height="24"'); // Preserving clipPath rect dimensions
expect(result).toContain('width="100%"'); // SVG root is replaced with 100%
});// Before modification
textarea.setSelectionRange(0, textarea.value.length); // Full selection
document.execCommand('insertText', false, nextValue); // Replacing entire document// After modification
const prevValue = textarea.value;
const minLength = Math.min(prevValue.length, nextValue.length);
let changeStart = 0;
while (changeStart < minLength && prevValue[changeStart] === nextValue[changeStart]) {
changeStart += 1;
}
let changeEndPrev = prevValue.length;
let changeEndNext = nextValue.length;
while (
changeEndPrev > changeStart &&
changeEndNext > changeStart &&
prevValue[changeEndPrev - 1] === nextValue[changeEndNext - 1]
) {
changeEndPrev -= 1;
changeEndNext -= 1;
}
textarea.setSelectionRange(changeStart, changeEndPrev);
document.execCommand('insertText', false, nextValue.slice(changeStart, changeEndNext));onChangecollectMarkdownImagesparseRichMarkdownSegmentsrehype-pretty-codevalue// Before modification
const markdownOptions = useMemo(
() => getMarkdownOptions({
adapters,
items: collectMarkdownImages(value), // O(n) image collection
}),
[adapters, value],
);// After modification
const deferredValue = React.useDeferredValue(value);
const markdownOptions = useMemo(
() => getMarkdownOptions({
adapters,
items: collectMarkdownImages(deferredValue),
}),
[adapters, deferredValue],
);Personal Portfolio · Article Archiving Platform
A gamification platform that turns boring CS studies into enjoyable habits
An interactive archiving site for readers of the fairy tale book «Beyond the Clouds»
A combination markdown editor package that the host app can directly control
마지막 프로젝트까지 모두 확인했습니다.