Un package d'éditeur Markdown combiné que l'application hôte peut contrôler directement
chaeditor est né du travail visant à extraire l'éditeur markdown interne du projet chaen en un package indépendant.
La partie la plus soignée a été de combien de contrôle l'application hôte peut-elle conserver, tout en extrayant les fonctionnalités d'édition en tant que package.
Les éditeurs markdown existants avaient souvent tendance à pencher d'un côté ou de l'autre. Ils étaient soit dépouillés au point de devoir être combinés entièrement, soit, à l'inverse, proposaient de nombreuses fonctionnalités mais une interface utilisateur et un mode de fonctionnement fortement figés, rendant difficile leur intégration naturelle dans un service. En réalité, chaque hôte a des besoins différents en termes de fonctionnalités et les points tels que l'aperçu des liens, le téléchargement de fichiers et le rendu d'images doivent être immédiatement connectés aux API existantes.
chaeditor regroupe l'expérience d'écriture jugée utile sur velog, Naver Blog, Discord et Notion en une implémentation de base, tout en permettant à l'application hôte de conserver la responsabilité de l'interface utilisateur, des métadonnées, du téléchargement et du rendu. Plutôt que d'être entièrement sans tête, notre objectif est de proposer un éditeur qui soit directement utilisable avec ses valeurs par défaut, mais que l'on peut décomposer, remplacer et connecter à la structure du service existant si nécessaire.
Nous n'avons pas seulement ajouté des fonctionnalités, mais avons également organisé la surface publique pouvant être utilisée par d'autres applications, ainsi que le flux opérationnel.
Nous avons structuré l'intérieur en couches FSD (Feature-Sliced Design). En séparant l'interface utilisateur d'édition, les fonctionnalités de rédaction, le modèle pur et les primitifs de base par couche, il est plus facile de déterminer où interrompre l'API publique après extraction du package.
Grâce à cette structure, nous avons pu diviser naturellement des points d'entrée comme core, react, panda-primitives.
Les parts de service avec des politiques variables, telles que l'API de téléchargement, l'aperçu des liens et le rendu des images, sont séparées en adaptateurs pour éviter de les fixer dans le package.
Si aucun adaptateur n'est fourni, l'action de la barre d'outils est automatiquement désactivée. Le point d'entrée chaeditor/default-host fournit une implémentation de base qui appelle , , , et intègre l'optimisation d'image avant le téléchargement (1600×1600px / 84 % de qualité).
MarkdownRenderer est aligné pour le rendu côté serveur, la surface de l'éditeur est un composant client et l'aperçu des liens et le téléchargement sont liés aux politiques des gestionnaires de routes ou des actions serveur. Lors de la réintégration dans chaen, seule la surface de l'éditeur a été remplacée par le package, tandis que l'API de téléchargement, le rendu basé sur , l'aperçu des liens et la coque primitive ont été conservés côté hôte.
C'est une structure pour appliquer de manière cohérente le système de conception de l'application hôte au sein de l'éditeur. Les composants , , , , , sont laissés ouverts comme des slots pour permettre à l'application hôte de les remplacer par ses propres composants.
Les slots non injectés se rabattent sur l'implémentation de base de panda-primitives. Injecté au sommet de l'arbre avec MarkdownPrimitiveProvider, il peut être utilisé partout à l'intérieur avec . En remplaçant les primitifs, les helpers de lien/image/équation et l'interface de superposition au sein de l'éditeur fonctionnent sur le même système de conception.
Les points de changement de style sont laissés comme propriétés personnalisées CSS. En laissant seulement les tokens Panda CSS ouverts, il est difficile pour l'application hôte de les réutiliser dans des environnements comme Tailwind, Emotion, styled-components, vanilla-extract. Ainsi, les variables CSS sont considérées comme le contrat extérieur, Panda restant simplement comme implémentation de base.
Le succès de la build ne suffit pas. Cela peut être dû au fait que l’alias @/ n’est pas interprété après la build, que le fichier de déclaration de type manque, ou que des dépendances internes soient éliminées de manière inattendue via treeshaking. Il est nécessaire de décompresser le véritable package npm tarball et de vérifier jusqu’à l’étape d’importation par un consommateur temporaire pour savoir si les exports du package, les déclarations de type, et la surface CJS/ESM sont fonctionnels.
Fichier : scripts/verify-package-surface.mjs
Nous gérons seulement deux groupes de test.
| Groupe | Critères | Workers |
|---|---|---|
node | Commentaire en haut du fichier @vitest-environment node, logique pure | Paramètre par défaut |
dom-ui | *.test.tsx, UI React et connexion DOM | Paramètre par défaut |
Mock SVG en trois types :
Les helpers markdown purs et les utilitaires de sélection vont vers les tests node, tandis que les zones nécessitant des interactions DOM comme l'éditeur · barre d'outils · visualiseur restent dans dom-ui.
Nous relions Storybook 10 + Chromatic pour vérifier si les modifications CSS dans chaque PR se diffusent sur d’autres surfaces via diff visuel. Lors de la mise à jour de version, le build Storybook et le déploiement Chromatic suivent automatiquement dans le script version.
Le Storybook est structuré comme référence pour comparer ce qui change et ce qui continue à être conforme dans le flux workflow d'éditeur / barre d'outils / renderer / embed, et non comme un « recueil de composants ». Le README, le wiki, et la documentation des presets de l’hôte sont également divisés de la même manière. Le Storybook sert à vérifier rapidement l’état visuel et les interactions, tandis que le wiki rassemble des guides et références intégrés en anglais/coréen, et la documentation des presets de l’hôte est conçue pour permettre de récupérer instantanément des exemples de connexion réels.
chaeditor a été conçu pour pouvoir être utilisé à la fois dans des applications React et Next.js. Par conséquent, les icônes SVG ne dépendent pas de composants spécifiques à un bundler particulier. Nous avons choisi de recevoir une chaîne SVG brute, de l'organiser à l'exécution et de la rendre en SVG inline. Ce choix, bien que flexible dans l'environnement source, a entraîné des bugs se manifestant uniquement lorsqu'il existe une différence entre le chemin dev et le chemin dist.
Juste après avoir connecté chaeditor à chaen, il y a eu un problème où l'icône Quote de la barre d'outils ne s'affichait pas. Elle apparaissait normalement dans Storybook, mais disparaissait uniquement dans l'environnement de production.
Storybook lit les fichiers sources directement sur le serveur de développement Vite, alors que l'application hôte réelle référence dist/ comme un paquet npm. Le composant icône récupère le fichier SVG sous forme de chaîne avec ?raw, le passe par à l'exécution pour le rendre en SVG inline.
normalizeSvgMarkup comprenait une étape de normalisation pour adapter le SVG à la taille de l'icône.
Ce regex s'applique globalement (g) à toute la chaîne SVG. quote.svg contenait une définition de clipPath exportée depuis Figma.
Le regex global a supprimé width et de , transformant ainsi en , rendant la zone de clip 0×0, ce qui a masqué toute l'icône.
Les SVG comportant un clipPath étaient uniquement quote.svg et github.svg. github.svg avait le même problème mais comme il était utilisé comme icône de lien, il a été découvert plus tard.
La correction consistait à limiter la correspondance externe à la seule balise ouverte <svg>.
Cette correction a conduit à l'ajout d'un test de régression pour gérer le cas de clipPath.
Lors de l'édition d'un article existant dans chaen, même l'application d'une seule fonction de la barre d'outils entraînait un délai visible dans les changements de l'interface utilisateur.
Cause 1 — execCommand remplace l'intégralité du document
L'action de la barre d'outils est une opération simple qui applique seulement un format Markdown à la sélection. Cependant, l'implémentation interne sélectionnait toujours de 0 à la fin, remplaçant tout le document avec insertText.
Bien que document.execCommand('insertText') soit une API obsolète, c’est la seule méthode permettant d’enregistrer les modifications dans la pile native undo du navigateur. Lorsque l'on appuyait sur gras dans un article de 50 000 caractères, le navigateur enregistrait les 50 000 dans la pile undo puis traitait de manière synchrone une mutation DOM pour réécrire les 50 000.
J'ai modifié pour calculer le préfixe et le suffixe commun entre l'ancienne et la nouvelle valeur, et ne remplacer que la section modifiée.
Dans un article de 50 000 caractères, lorsque vous appliquez le formatage en gras, seule la portion de texte de **text** 10 caractères environ est remplacée. Le coût de execCommand passe de O(n) à O(quantité de changement), et l'annulation ne rétablit que la portion effectivement modifiée.
Cause 2 — Rendu de l'aperçu exécuté de manière synchrone à chaque entrée
Lorsque est appelé, le panneau d'aperçu est également re-rendu de manière synchrone. (scan du document complet), (parsing des blocs personnalisés), (mise en surbrillance du code) étaient tous exécutés de manière synchrone à chaque modification de .
La valeur dédiée à l’aperçu a été séparée à l’aide de React.useDeferredValue.
Dans React 19, useDeferredValue gère d'abord les mises à jour urgentes telles que les entrées utilisateur, et reporte le rendu de l'aperçu à une priorité plus basse. Le textarea réagit immédiatement et l’aperçu est mis à jour par React lorsque le temps le permet.
L'essentiel était que le coût était « bien trop élevé par rapport à une petite opération de formatage ». Plus le document devient long, plus ce type de problème devient apparent. Par conséquent, le chemin de transformation de la barre d'outils et de rendu de l'aperçu a été allégé en priorité. Il reste encore de la marge pour réduire davantage, mais les goulets d'étranglement urgents qui entravent réellement le flux d'édition ont été rapidement résolus et déployés à cette étape.
En rendant le package open-source, l'accent a été mis sur la délimitation claire des responsabilités et sur la documentation. Dans une structure étendue, si les responsabilités entre le package et l'application hôte sont floues, il est difficile pour l'intégrateur de savoir où intervenir. L'adaptateur hôte, le registre primitif et le contrat de variables CSS expriment tous ces limites en code, tandis que le README et Storybook les expliquent en tant que documents.
En le réintégrant dans « chaen », nous avons pu vérifier si ces frontières sont réellement maintenues et, au cours de ce processus, nous avons corrigé les bugs et les problèmes de performance au niveau du package.
src/
├── widgets/editor/ # MarkdownEditor (combinaison de haut niveau)
├── features/edit-markdown/ # barre d'outils / image / fichier / lien / maths / vidéo / formatage
├── entities/editor-core/ # utilitaires de modèle pur (sans React)
├── shared/ # primitives UI + librairies partagées
├── core/ # barils API publics (ré-exportation des entités)
├── react/ # barils API publics (ré-exportation des fonctionnalités + widgets)
└── panda-primitives/ # implémentation basique de Panda CSStype 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; // Composant image personnalisé comme 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;import { createChaeditorThemeVars } from 'chaeditor/core';
const myTheme = createChaeditorThemeVars({
primary: '#3b82f6',
surface: '#ffffff',
text: '#18181b',
});
// → { '--chaeditor-color-primary': '#3b82f6', ... }
// → <div style={myTheme}> 으로 적용.tgz déployé réel via npm pack/.tmp/package-surface-smoke/)core, react, default-host, panda-primitives)tsc --noEmit)*.svg → Composant React factice (svg-component.tsx)*.svg?url → Chaîne d'URL factice (svg-mock-url.ts)*.svg?raw → Chaîne SVG en ligne (plugin inline Vite)// app-icons.tsx
import QuoteSvg from '@/shared/assets/icons/quote.svg?raw';
const rawSvgMarkup = resolveRawSvgMarkup(svgSource); // Évaluation fixe du module
const svgMarkup = React.useMemo(
() => scopeSvgMarkupIds(normalizeSvgMarkup(rawSvgMarkup), iconScopeId),
[assetSvgMarkup, iconScopeId],
);// Avant 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"/> ← C'est là le problème
</clipPath>
</defs><rect width="24" height="24"><rect/>// Après modification
.replace(/<svg\b[^>]*>/, svgTag => svgTag.replace(/\s(width|height)="[^"]*"/gi, ''))it('Sous un balisage SVG brut avec un clipPath, normalizeSvgMarkup doit préserver les dimensions des éléments du clipPath', () => {
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"'); // Préservation des dimensions du rectangle clipPath
expect(result).toContain('width="100%"'); // La racine SVG est remplacée par 100%
});// Avant modification
textarea.setSelectionRange(0, textarea.value.length); // Sélection complète
document.execCommand('insertText', false, nextValue); // Remplacement complet du document// Après 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// Avant modification
const markdownOptions = useMemo(
() => getMarkdownOptions({
adapters,
items: collectMarkdownImages(value), // O(n) collecte d'images
}),
[adapters, value],
);// Après modification
const deferredValue = React.useDeferredValue(value);
const markdownOptions = useMemo(
() => getMarkdownOptions({
adapters,
items: collectMarkdownImages(deferredValue),
}),
[adapters, deferredValue],
);Portfolio personnel · Site d'archivage d'articles
Une plateforme de gamification qui transforme l'étude en informatique en une habitude agréable
Site d'interaction pour les lecteurs du livre pour enfants 'Au-delà des Nuages'
Un package d'éditeur Markdown combiné que l'application hôte peut contrôler directement
마지막 프로젝트까지 모두 확인했습니다.