diff --git a/.docs/editor/PostEditor Nodes.md b/.docs/editor/PostEditor Nodes.md new file mode 100644 index 00000000..43df19a5 --- /dev/null +++ b/.docs/editor/PostEditor Nodes.md @@ -0,0 +1,403 @@ + +----- + +# PostEditor 노드 시스템 깊게 파보기 ⛏️ + +## 시작하며 + +PostEditor가 어떻게 이미지나 코드 블록 같은 다양한 콘텐츠를 다룰 수 있을까요? 그냥 `div`에 `contentEditable` 속성만으로는 어림도 없죠. 그 비밀은 바로 Lexical 프레임워크의 심장과도 같은 **노드(Node) 시스템**에 있습니다. + +노드는 에디터 안에 들어가는 모든 콘텐츠의 기본 단위예요. 텍스트 한 글자, 이미지, 목록 하나하나가 모두 각자의 규칙을 가진 '노드' 객체로 관리되죠. 이번 문서에서는 PostEditor가 어떻게 이 노드 시스템을 활용하고, 우리가 직접 만든 커스텀 노드는 어떤 원리로 돌아가는지 속속들이 파헤쳐 보겠습니다. + +----- + +## 목차 + +- [Lexical 노드, 일단 친해지기](https://www.google.com/search?q=%23lexical-%EB%85%B8%EB%93%9C-%EC%9D%BC%EB%8B%A8-%EC%B9%9C%ED%95%B4%EC%A7%80%EA%B8%B0) +- [그래서, 노드를 왜 써야 할까요?](https://www.google.com/search?q=%23%EA%B7%B8%EB%9E%98%EC%84%9C-%EB%85%B8%EB%93%9C%EB%A5%BC-%EC%99%9C-%EC%8D%A8%EC%95%BC-%ED%95%A0%EA%B9%8C%EC%9A%94) +- [PostEditor의 커스텀 노드 파헤치기](https://www.google.com/search?q=%23posteditor%EC%9D%98-%EC%BB%A4%EC%8A%A4%ED%85%80-%EB%85%B8%EB%93%9C-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0) + - [1. ImageNode: 이미지 데이터의 설계도](https://www.google.com/search?q=%231-imagenode-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EB%8D%B0%EC%9D%B4%ED%84%B0%EC%9D%98-%EC%84%A4%EA%B3%84%EB%8F%84) + - [2. LazyEditorImage: 눈에 보이는 이미지 UI](https://www.google.com/search?q=%232-lazyeditorimage-%EB%88%88%EC%97%90-%EB%B3%B4%EC%9D%B4%EB%8A%94-%EC%9D%B4%EB%AF%B8%EC%A7%80-ui) +- [노드는 어떻게 태어나고 변할까요? (생명주기)](https://www.google.com/search?q=%23%EB%85%B8%EB%93%9C%EB%8A%94-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%83%9C%EC%96%B4%EB%82%98%EA%B3%A0-%EB%B3%80%ED%95%A0%EA%B9%8C%EC%9A%94-%EC%83%9D%EB%AA%85%EC%A3%BC%EA%B8%B0) +- [빠릿빠릿한 에디터를 위한 최적화 이야기](https://www.google.com/search?q=%23%EB%B9%A0%EB%A6%BF%EB%B9%A0%EB%A6%BF%ED%95%9C-%EC%97%90%EB%94%94%ED%84%B0%EB%A5%BC-%EC%9C%84%ED%95%9C-%EC%B5%9C%EC%A0%81%ED%99%94-%EC%9D%B4%EC%95%BC%EA%B8%B0) +- [새로운 기능 추가하기 (확장성)](https://www.google.com/search?q=%23%EC%83%88%EB%A1%9C%EC%9A%B4-%EA%B8%B0%EB%8A%A5-%EC%B6%94%EA%B0%80%ED%95%98%EA%B8%B0-%ED%99%95%EC%9E%A5%EC%84%B1) + +----- + +## 🔬 Lexical 노드, 일단 친해지기 + +### 노드의 핵심, '불변성' + +Lexical의 모든 노드는 **불변(Immutable) 객체**예요. 이게 왜 중요할까요? 한 번 만들어진 노드는 직접 수정하는 게 아니라, 항상 '복사본'을 만들어 변경하기 때문에 상태 관리가 훨씬 쉬워지고 버그가 줄어들어요. React가 state를 다루는 방식과 비슷하죠. + +```typescript +// 노드를 바꾸고 싶을 땐? +const node = new ImageNode(src, altText, maxWidth); +const updatedNode = node.getWritable(); // 쓰기 가능한 복사본을 만들고 +updatedNode.__width = newWidth; // 복사본의 값을 변경! +``` + +### 노드들의 가계도 + +Lexical에는 여러 종류의 노드가 있고, 각자 역할이 정해져 있어요. + +```mermaid +graph TD + A[LexicalNode] --> B[DecoratorNode] + A --> C[ElementNode] + A --> D[TextNode] + A --> E[LineBreakNode] + + B --> F[ImageNode] + C --> G[HeadingNode] + C --> H[QuoteNode] + C --> I[CodeNode] + C --> J[ListNode] +``` + +**노드 종류별 한 줄 요약:** + +| 노드 타입 | 설명 | 예시 | +| :--- | :--- | :--- | +| **DecoratorNode** | 이미지나 비디오처럼 UI를 직접 그리는 특별한 노드예요. (React 컴포넌트 사용) | ImageNode, VideoNode | +| **ElementNode** | 다른 노드들을 자식으로 품을 수 있는 컨테이너 노드예요. | 문단(p), 제목(h1), 목록(ul) | +| **TextNode** | 말 그대로 글자를 담는 가장 기본적인 노드예요. | "안녕하세요" 같은 일반 텍스트 | +| **LineBreakNode** | 엔터(줄바꿈)를 표현하는 노드예요. | `\n` | + +----- + +## 🤔 그래서, 노드를 왜 써야 할까요? + +그냥 HTML로 하면 편할 텐데, 왜 굳이 복잡해 보이는 노드 시스템을 쓸까요? 몇 가지 결정적인 이유가 있어요. + +### 1\. 안전하고 체계적인 콘텐츠 관리 + +단순히 `innerHTML`을 사용하면 온갖 종류의 HTML 태그가 섞여 들어와 통제 불능이 될 수 있어요. 하지만 노드 시스템을 쓰면 우리가 허용한 콘텐츠만, 정해진 구조대로 들어오게 할 수 있죠. + +```typescript +// ❌ HTML 기반: 무슨 태그가 들어올지 몰라 불안해요. +const content = '...'; + +// ✅ 노드 기반: 정해진 타입과 값만 허용해서 안전해요. +const imageNode = $createImageNode({ + src: string, + altText: string, + maxWidth: number +}); +``` + +### 2\. 데이터를 깔끔하게 저장하고 불러오기 + +노드는 완벽하게 JSON 형태로 변환(직렬화)할 수 있어요. 덕분에 에디터의 상태를 데이터베이스에 저장하고, 다시 불러와서 원래 모습 그대로 복원하는 게 아주 쉬워요. + +```typescript +// 직렬화 (DB에 저장할 때) +const serialized = imageNode.exportJSON(); +// { type: 'image', src: '...', altText: '...', ... } + +// 역직렬화 (DB에서 불러올 때) +const restoredNode = ImageNode.importJSON(serialized); +``` + +### 3\. 기능 하나하나를 내 마음대로 + +각 노드마다 고유한 기능과 동작을 직접 정의할 수 있어요. 예를 들어, 이미지 노드는 복사/붙여넣기 할 때 `` 태그로 변환하고, 에디터 안에서는 크기 조절이 가능한 React 컴포넌트로 보이게 만드는 식이죠. + +```typescript +class ImageNode extends DecoratorNode { + // DOM을 어떻게 만들지? + createDOM(config: EditorConfig): HTMLElement { ... } + + // DOM 업데이트는 어떻게 할까? (최적화) + updateDOM(): boolean { ... } + + // 어떤 React 컴포넌트를 보여줄까? + decorate(): JSX.Element { ... } + + // 복사/붙여넣기는 어떻게 처리할까? + static importDOM(): DOMConversionMap { ... } +} +``` + +### 4\. 똑똑한 렌더링으로 성능 잡기 + +Lexical은 변경된 노드만 콕 집어서 다시 그리기 때문에 아주 긴 글에서도 성능이 좋아요. 전체를 다시 렌더링하는 것보다 훨씬 효율적이죠. + +----- + +## 🛠️ PostEditor의 커스텀 노드 파헤치기 + +PostEditor에서는 기본 노드 외에 우리가 직접 만든 커스텀 노드를 사용해요. 대표적인 `ImageNode`를 통해 노드가 실제로 어떻게 동작하는지 살펴볼게요. + +### 1\. ImageNode: 이미지 데이터의 설계도 + +**파일**: `ImageNode.tsx` +**역할**: 이미지에 대한 모든 정보(URL, 대체 텍스트, 크기 등)를 가지고 있고, 에디터가 이해할 수 있도록 하는 데이터 객체(설계도) 역할. + +#### 뼈대 살펴보기 + +```typescript +export class ImageNode extends DecoratorNode { + // 이미지의 상태를 저장하는 속성들 + __src: string; // 이미지 주소 + __altText: string; // 대체 텍스트 + __width: 'inherit' | number; // 너비 + __height: 'inherit' | number; // 높이 + __maxWidth: number; // 최대 너비 +``` + +**잠깐, 왜 이름 앞에 밑줄 두 개(`__`)가 붙을까요?** +이건 Lexical의 약속(컨벤션)이에요. "이건 노드의 핵심 상태 값이니까 외부에서 함부로 바꾸지 말고, 정해진 메서드를 통해 다뤄주세요\!"라는 의미죠. 노드의 불변성을 지키기 위한 장치이기도 해요. + +#### 주요 기능 뜯어보기 + +**1. 노드를 만드는 방법 (`constructor`와 `팩토리 함수`)** + +노드를 생성할 때는 `new ImageNode(...)` 보다 `$createImageNode(...)` 라는 팩토리 함수를 사용하는 게 더 편하고 안전해요. 기본값을 설정해주거나 추가적인 로직을 넣기 좋거든요. + +```typescript +// 팩토리 함수: 노드를 좀 더 편하게 만들게 도와줘요. +export const $createImageNode = ({...}: ImagePayload) => { + return new ImageNode(...); +}; +``` + +**2. 저장하고 불러오는 방법 (`exportJSON` / `importJSON`)** + +위에서 봤던 직렬화/역직렬화를 담당하는 메서드예요. DB에 저장할 형식과 DB에서 읽어온 데이터를 노드로 변환하는 규칙을 정의하죠. + +```typescript +// JSON으로 내보내기 (DB 저장용) +exportJSON(): SerializedImageNode { ... } + +// JSON에서 가져오기 (DB 복원용) +static importJSON(serializedNode: SerializedImageNode): ImageNode { ... } +``` + +**3. HTML과 소통하는 방법 (`importDOM` / `exportDOM`)** + +외부에서 `` 태그를 복사해서 붙여넣었을 때, 이걸 `ImageNode`로 변환하는 규칙(`importDOM`)과, 에디터 내용을 HTML로 내보낼 때 `ImageNode`를 `` 태그로 바꾸는 규칙(`exportDOM`)을 정해요. + +```typescript +// HTML 태그를 ImageNode로 변환 (붙여넣기) +static importDOM(): DOMConversionMap | null { ... } + +// ImageNode를 HTML 태그로 변환 (내보내기) +exportDOM(): DOMExportOutput { ... } +``` + +**4. 화면에 그리는 방법 (`decorate`)** + +이게 바로 `DecoratorNode`의 꽃이에요\! 데이터를 가진 노드가 실제 화면에 어떤 모습으로 보일지를 정하죠. 여기서는 `LazyEditorImage`라는 React 컴포넌트를 뿅 하고 보여주라고 지정했네요. + +```typescript +// 이 노드는 이 React 컴포넌트로 그려주세요! +decorate(): JSX.Element { + return ( + + + + ); +} +``` + +**5. DOM 뼈대 만들기 (`createDOM` / `updateDOM`)** + +React 컴포넌트를 감쌀 기본적인 DOM 태그(`` 등)를 만들어요. `updateDOM`이 `false`인 이유는, 세부적인 렌더링은 React(`LazyEditorImage`)가 알아서 할 테니 Lexical은 신경 쓰지 말라는 의미예요. "여긴 React 구역이니 DOM 업데이트는 우리가 할게\!" 하는 거죠. + +#### ImageNode, 한눈에 보기 + +* **장점**: 타입이 명확하고, DB 저장/복원이 쉽고, HTML과도 잘 호환돼요. +* **단점**: 간단한 이미지 하나 보여주자고 만드는 코드가 좀 길고 복잡하죠. + +----- + +### 2\. LazyEditorImage: 눈에 보이는 이미지 UI + +**파일**: `LazyEditorImage.tsx` +**역할**: `ImageNode`가 가진 데이터를 바탕으로 실제 유저 눈에 보이는 UI를 그리고, 이미지 선택이나 삭제 같은 상호작용을 처리해요. + +`ImageNode`가 데이터와 뼈대라면, `LazyEditorImage`는 실제 우리 눈에 보이는 예쁜 옷과 같아요. + +#### 어떤 기능들이 들어있을까요? + +**1. 이미지 선택 상태 관리** + +Lexical이 제공하는 `useLexicalNodeSelection` 훅을 사용해서 이미지가 선택되었는지 아닌지를 쉽게 관리할 수 있어요. + +```typescript +// 이 훅 하나로 선택 여부, 선택하기, 선택 해제하기 기능을 다 쓸 수 있어요. +const [isSelected, setSelected, clearSelection] = useLexicalNodeSelection(nodeKey); +const isFocused = isSelected && isEditable; +``` + +**2. 이미지 삭제하기** + +이미지가 선택된 상태에서 `Backspace`나 `Delete` 키를 누르면 `deleteImage` 함수가 실행되어 해당 노드를 에디터에서 삭제해요. + +```typescript +const deleteImage = useCallback((payload: KeyboardEvent) => { + // ... + if ($isImageNode(node)) { + node.remove(); // 노드를 트리에서 제거! + } + // ... +}, [editor, isSelected]); +``` + +**3. 다양한 커맨드 등록하기** + +`useEffect` 안에서 `editor.registerCommand`를 사용해 특정 이벤트(클릭, 키보드 입력 등)가 발생했을 때 어떤 함수를 실행할지 등록해요. `mergeRegister`는 여러 커맨드를 등록하고 한 번에 정리할 수 있게 도와주는 유틸리티 함수예요. + +```typescript +useEffect(() => { + // 컴포넌트가 생길 때 CLICK, KEY_DELETE 커맨드를 등록해요. + const unregister = mergeRegister( + editor.registerCommand(CLICK_COMMAND, onClick, ...), + editor.registerCommand(KEY_DELETE_COMMAND, deleteImage, ...), + // ... + ); + + // 컴포넌트가 사라질 때 등록했던 커맨드를 깨끗하게 정리해요. + return () => unregister(); +}, [editor]); +``` + +**4. UI 그리기** + +실제 이미지는 `LazyImage` 컴포넌트를 통해 그리고, 선택되었을 때(`isFocused`)는 추가적인 UI(예: "삭제하려면 backspace를 누르세요" 같은 안내 메시지)를 보여줘요. + +```typescript +return ( + // ... +
+ {/* 선택됐을 때만 보이는 안내 메시지 */} + {isFocused && ( +
backspace 또는 del 키를 통해 삭제
+ )} + + {/* 실제 이미지를 보여주는 컴포넌트 */} + +
+ // ... +); +``` + +#### LazyEditorImage의 특징 + +* **최적화**: 이미지가 화면에 보일 때만 로딩(`Lazy Loading`)하고, 불필요한 함수 생성을 막기 위해 `useCallback`을 사용해요. +* **사용자 경험(UX)**: 이미지를 선택하면 테두리로 시각적 피드백을 주고, 키보드로 쉽게 삭제할 수 있게 만들어 사용자가 편하게 쓸 수 있어요. + +----- + +## 🔄 노드는 어떻게 태어나고 변할까요? (생명주기) + +### 1\. 이미지 노드가 만들어지는 과정 + +```mermaid +sequenceDiagram + participant U as 유저 + participant P as 플러그인 + participant E as 에디터 + participant N as ImageNode + participant C as LazyEditorImage + + U->>P: 이미지를 끌어다 놓는다 + P->>E: "이미지 넣어줘!" (INSERT_IMAGE_COMMAND) + E->>N: `$createImageNode()`로 노드 생성 + N->>E: "나 여기 들어갈래!" (노드 트리에 삽입) + E->>N: "이제 화면에 네 모습 보여줘." (decorate() 호출) + N->>C: "이 컴포넌트로 그려줘!" (LazyEditorImage 반환) + C->>U: 화면에 이미지가 짠! +``` + +### 2\. 노드 정보가 바뀌는 과정 (e.g. 크기 조절) + +불변성 원칙에 따라, 노드를 직접 바꾸는 게 아니라 복사본을 만들어 수정해요. + +```typescript +// 1. 유저가 이미지 크기를 조절하면 +editor.update(() => { + const node = $getNodeByKey(imageNodeKey); + + // 2. 쓰기 가능한 복사본을 만들어서 + const writableNode = node.getWritable(); + + // 3. 복사본의 값을 바꿔치기해요. + writableNode.setWidthAndHeight(newWidth, newHeight); + + // 4. 그럼 Lexical이 알아서 화면을 다시 그려줘요. +}); +``` + +----- + +## ⚡️ 빠릿빠릿한 에디터를 위한 최적화 이야기 + +### 1\. 메모리 사용량 줄이기 + +**지연 로딩 (Lazy Loading)** +`LazyImage` 컴포넌트는 React.lazy를 사용해서 만들었어요. 덕분에 당장 화면에 보이지 않는 이미지는 로드하지 않아 초기 로딩 속도가 빨라지죠. + +```typescript +// LazyImage 컴포넌트는 필요할 때만 불러와요. +const LazyImage = lazy(() => import('@/components/commons/LazyImage')); + + + + +``` + +### 2\. 렌더링 속도 올리기 + +**똑똑한 UI 렌더링** +이미지가 선택되었을 때만 컨트롤 UI가 보이도록 해서 불필요한 렌더링을 피해요. + +```typescript +{isFocused && (
...
)} +``` + +**똑똑한 이벤트 핸들러** +`useCallback`을 사용해 `isSelected` 같은 상태가 변경될 때만 `onClick` 함수를 새로 만들도록 해서 렌더링 성능을 아낄 수 있어요. + +----- + +## 🧩 새로운 기능 추가하기 (확장성) + +PostEditor의 노드 시스템은 새로운 기능을 붙이기에 아주 좋은 구조예요. 만약 '비디오' 노드를 추가하고 싶다면 어떻게 해야 할까요? + +1. 비디오 정보를 담을 `VideoNode` 클래스를 만들고, +2. 화면에 보여줄 `VideoPlayer` React 컴포넌트를 만들고, +3. `VideoNode`의 `decorate` 메서드가 `VideoPlayer`를 반환하게 연결해주면 끝\! + +여기에 "비디오 추가" 커맨드와 그 커맨드를 처리하는 플러그인까지 만들면 새로운 기능을 완벽하게 추가할 수 있죠. + +----- + +## 👍👎 노드 시스템의 장단점 + +### ✅ 좋은 점 + +1. **안전성**: 타입스크립트 덕분에 타입 관련 버그가 거의 없어요. +2. **체계성**: 모든 콘텐츠가 정해진 구조를 가져서 데이터를 다루기 편해요. +3. **데이터 관리**: DB에 저장하고 불러오기가 아주 깔끔해요. +4. **확장성**: 비디오, 투표, 지도 등 새로운 기능을 추가하기 쉬워요. +5. **성능**: 똑똑한 렌더링 덕분에 긴 글에서도 빠릿빠릿해요. + +### ⚠️ 아쉬운 점 + +1. **배워야 할 게 좀 있어요**: Lexical의 노드 시스템에 익숙해지는 데 시간이 필요해요. +2. **코드가 길어져요**: 간단한 기능 하나를 추가하려 해도 여러 파일을 만들어야 해서 코드가 복잡해 보일 수 있어요. +3. **디버깅**: 눈에 보이는 HTML과 실제 데이터 구조(노드 트리)가 달라서 디버깅이 조금 더 까다로울 수 있어요. + +----- + +## 마무리하며 + +PostEditor의 노드 시스템은 처음엔 복잡해 보일 수 있지만, 그 구조를 이해하고 나면 **안정적이고 확장성 높은 에디터**를 만드는 데 이보다 더 좋은 방법은 없다는 걸 알게 됩니다. + +- **불변성**으로 데이터 흐름을 예측 가능하게 만들고, +- 데이터(**Node**)와 화면(**Component**)의 **역할을 명확히 분리**해서 코드를 깔끔하게 유지하고, +- **최적화 기법**들을 녹여내어 좋은 성능을 보장하죠. + +이런 탄탄한 설계 덕분에 PostEditor는 앞으로 더 복잡하고 다양한 기능들을 안정적으로 품을 수 있는 훌륭한 기반을 갖추게 되었습니다. \ No newline at end of file diff --git a/.docs/editor/PostEditor Plugins.md b/.docs/editor/PostEditor Plugins.md new file mode 100644 index 00000000..0aa8e3ba --- /dev/null +++ b/.docs/editor/PostEditor Plugins.md @@ -0,0 +1,387 @@ + +----- + +# PostEditor 플러그인 탐험기 🗺️ + +## 시작하며 + +PostEditor가 글꼴을 바꾸고, 이미지를 끌어다 놓고, 마크다운을 뿅 하고 변환하는 등 마법 같은 일들을 해내는 비결은 무엇일까요? 바로 **플러그인(Plugin) 시스템** 덕분입니다. + +PostEditor는 12개의 전문 플러그인들이 레고 블록처럼 각자 맡은 역할을 수행하고, 서로 유기적으로 협력해서 만들어진 결과물이에요. 이번 문서에서는 에디터의 핵심 기능을 담당하는 플러그인들이 각각 어떤 역할을 하고, 내부적으로 어떻게 똑똑하게 작동하는지 함께 탐험해 보겠습니다. + +----- + +## 목차 + +- [플러그인은 어떻게 작동할까? (아키텍처)](https://www.google.com/search?q=%23%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%9E%91%EB%8F%99%ED%95%A0%EA%B9%8C-%EC%95%84%ED%82%A4%ED%85%8D%EC%B2%98) +- [핵심 플러그인 톺아보기](https://www.google.com/search?q=%23%ED%95%B5%EC%8B%AC-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-%ED%86%BA%EC%95%84%EB%B3%B4%EA%B8%B0) + - [1. ToolbarPlugin: 에디터의 얼굴](https://www.google.com/search?q=%231-toolbarplugin-%EC%97%90%EB%94%94%ED%84%B0%EC%9D%98-%EC%96%BC%EA%B5%B4) + - [2. CodeActionPlugin: 개발자를 위한 작은 배려](https://www.google.com/search?q=%232-codeactionplugin-%EA%B0%9C%EB%B0%9C%EC%9E%90%EB%A5%BC-%EC%9C%84%ED%95%9C-%EC%9E%91%EC%9D%80-%EB%B0%B0%EB%A0%A4) + - [3. FloatingLinkEditorPlugin: 똑똑한 링크 편집기](https://www.google.com/search?q=%233-floatinglinkeditorplugin-%EB%98%91%EB%98%91%ED%95%9C-%EB%A7%81%ED%81%AC-%ED%8E%B8%EC%A7%91%EA%B8%B0) + - [4. ImagePlugin: 이미지 삽입의 해결사](https://www.google.com/search?q=%234-imageplugin-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%82%BD%EC%9E%85%EC%9D%98-%ED%95%B4%EA%B2%B0%EC%82%AC) + - [5. DraggablePlugin: 블록을 자유롭게 이동](https://www.google.com/search?q=%235-draggableplugin-%EB%B8%94%EB%A1%9D%EC%9D%84-%EC%9E%90%EC%9C%A0%EB%A1%AD%EA%B2%8C-%EC%9D%B4%EB%8F%99) + - [6. ClipboardPlugin: 스마트한 복사/붙여넣기](https://www.google.com/search?q=%236-clipboardplugin-%EC%8A%A4%EB%A7%88%ED%8A%B8%ED%95%9C-%EB%B3%B5%EC%82%AC%EB%B6%99%EC%97%AC%EB%84%A3%EA%B8%B0) + - [7. GrabContentPlugin: 실시간 내용 전달자](https://www.google.com/search?q=%237-grabcontentplugin-%EC%8B%A4%EC%8B%9C%EA%B0%84-%EB%82%B4%EC%9A%A9-%EC%A0%84%EB%8B%AC%EC%9E%90) + - [8. HighlightCodePlugin: 코드를 아름답게](https://www.google.com/search?q=%238-highlightcodeplugin-%EC%BD%94%EB%93%9C%EB%A5%BC-%EC%95%84%EB%A6%84%EB%8B%B5%EA%B2%8C) + - [9. InitContentPlugin: 저장된 글 불러오기](https://www.google.com/search?q=%239-initcontentplugin-%EC%A0%80%EC%9E%A5%EB%90%9C-%EA%B8%80-%EB%B6%88%EB%9F%AC%EC%98%A4%EA%B8%B0) + - [10. MaxIndentPlugin: 들여쓰기 경찰](https://www.google.com/search?q=%2310-maxindentplugin-%EB%93%A4%EC%97%AC%EC%93%B0%EA%B8%B0-%EA%B2%BD%EC%B0%B0) + - [11. MarkdownShortcuts: 마크다운 마법사](https://www.google.com/search?q=%2311-markdownshortcuts-%EB%A7%88%ED%81%AC%EB%8B%A4%EC%9A%B4-%EB%A7%88%EB%B2%95%EC%82%AC) + - [12. Utils: 우리들의 연장통](https://www.google.com/search?q=%2312-utils-%EC%9A%B0%EB%A6%AC%EB%93%A4%EC%9D%98-%EC%97%B0%EC%9E%A5%ED%86%B5) +- [플러그인들은 어떻게 협업할까?](https://www.google.com/search?q=%23%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8%EB%93%A4%EC%9D%80-%EC%96%B4%EB%96%BB%EA%B2%8C-%ED%98%91%EC%97%85%ED%95%A0%EA%B9%8C) +- [빠릿한 에디터를 만드는 비결](https://www.google.com/search?q=%23%EB%B9%A0%EB%A6%BF%ED%95%9C-%EC%97%90%EB%94%94%ED%84%B0%EB%A5%BC-%EB%A7%8C%EB%93%9C%EB%8A%94-%EB%B9%84%EA%B2%B0) + +----- + +## 🤔 플러그인은 어떻게 작동할까? (아키텍처) + +### 중앙 소통 채널, '커맨드 시스템' + +플러그인들은 서로를 직접 호출하지 않아요. 대신 Lexical의 **커맨드 시스템**이라는 중앙 소통 채널을 통해 "나 이런 일 좀 해줘\!" 하고 요청(dispatch)하거나, "이런 요청이 오면 내가 처리할게\!" 하고 등록(register)하는 방식으로 동작해요. 덕분에 각 플러그인은 독립성을 유지하면서도 서로 협력할 수 있죠. + +```typescript +// 이런 패턴으로 커맨드를 등록해요 +editor.registerCommand( + COMMAND_TYPE, // 어떤 종류의 요청인지 (예: FORMAT_TEXT_COMMAND) + commandHandler, // 요청이 오면 실행할 함수 + COMMAND_PRIORITY_LEVEL // "내가 먼저 처리할래!" (우선순위) +); +``` + +### 플러그인의 삶 (생명주기) + +대부분의 플러그인은 `useEffect`를 이용해 에디터가 처음 생길 때 필요한 기능을 등록하고, 사라질 때 깨끗하게 정리하는 간단한 구조를 가져요. + +```typescript +export const MyPlugin = () => { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + // 1. 플러그인이 태어날 때 + // 2. "이런 커맨드 오면 나한테 알려줘!" 하고 등록 + const cleanup = editor.registerCommand(...); + + // 3. 플러그인이 죽을 때 등록했던 걸 해제 (메모리 누수 방지!) + return cleanup; + }, [editor]); + + return null; // 보통 UI는 없어요 +}; +``` + +----- + +## 🚀 핵심 플러그인 톺아보기 + +### 1\. ToolbarPlugin: 에디터의 얼굴 + +**파일**: `ToolbarPlugin.tsx` +**역할**: 우리 눈에 가장 잘 보이는 상단 툴바예요. 글자 스타일을 바꾸는 모든 버튼과 드롭다운의 로직을 담당하죠. + +#### 어떻게 작동하나요? + +**1. 현재 텍스트 상태 추적하기** +유저가 드래그해서 선택한 영역의 텍스트가 굵게(bold)인지, 기울임(italic)인지 등을 실시간으로 감지해서 툴바 버튼의 활성화 상태를 바꿔줘요. + +```typescript +const updateToolbarOnSelect = useCallback(() => { + const selection = $getSelection(); + if ($isRangeSelection(selection)) { + // 선택 영역이 'bold' 포맷을 가졌는지 확인 + setIsBold(selection.hasFormat('bold')); + // ... 다른 포맷들도 확인 + } +}, []); +``` + +**2. 상태 변화 감지하고 툴바 업데이트하기** +`SELECTION_CHANGE_COMMAND`와 `registerUpdateListener`를 이용해 유저의 선택 영역이 바뀌거나 글자 내용이 바뀔 때마다 `updateToolbarOnSelect` 함수를 호출해서 툴바 UI를 최신 상태로 유지해요. + +```typescript +useEffect(() => { + return mergeRegister( + // 선택 영역이 바뀔 때마다 알려줘! + editor.registerCommand( + SELECTION_CHANGE_COMMAND, + () => { updateToolbarOnSelect(); return false; }, + COMMAND_PRIORITY_CRITICAL + ), + // 에디터 내용이 바뀔 때마다 알려줘! + editor.registerUpdateListener(...) + ); +}, [editor, updateToolbarOnSelect]); +``` + +> `useCallback`으로 함수를 감싸서 불필요한 리렌더링을 막는 건 성능을 위한 기본 센스\! + +**3. 텍스트 스타일 적용하기** +유저가 '굵게' 버튼을 누르면, `FORMAT_TEXT_COMMAND`를 에디터에 보내 "선택된 영역을 굵게 만들어줘\!" 하고 요청해요. + +```typescript +const formatText = (format: TextFormatType) => { + editor.dispatchCommand(FORMAT_TEXT_COMMAND, format); +}; + + +``` + +----- + +### 2\. CodeActionPlugin: 개발자를 위한 작은 배려 + +**파일**: `CodeActionPlugin.tsx` +**역할**: 코드 블록 위에 마우스를 올리면 오른쪽 위에 '언어 이름'과 '복사' 버튼이 스르륵 나타나게 해줘요. + +#### 어떻게 작동하나요? + +**1. 똑똑한 이벤트 처리, 디바운싱(Debounce)** +마우스를 1px만 움직여도 `mousemove` 이벤트는 수십 번씩 발생해요. 이걸 그대로 다 처리하면 성능이 나빠지겠죠? 그래서 **디바운싱**을 사용해 마우스 움직임이 "잠깐 멈췄을 때" 한 번만 위치 계산 로직을 실행하도록 만들었어요. + +```typescript +// 커스텀 디바운스 훅을 사용 +const debouncedOnMouseMove = useDebounce( + (event: MouseEvent) => { + // 마우스 위치 계산 및 UI 표시 로직... + }, + 50, // 50ms 동안 추가 움직임이 없으면 실행 + 1000 // 아무리 길어도 1초에 한 번은 실행 +); +``` + +**2. 필요할 때만 이벤트 듣기** +에디터에 코드 블록이 하나도 없다면 마우스 움직임을 감시할 필요가 없겠죠? `registerMutationListener`로 `CodeNode`의 생성/삭제를 감지해서, 코드 블록이 있을 때만 마우스 이벤트를 활성화해요. 똑똑한 최적화죠. + +```typescript +useEffect(() => { + return editor.registerMutationListener(CodeNode, (mutations) => { + // ... 코드 노드 개수 추적 + // 코드 블록이 하나라도 있으면 true, 없으면 false + setShouldListenMouseMove(codeSetRef.current.size > 0); + }); +}, [editor]); +``` + +**3. 코드 복사하기** +복사 버튼을 누르면 현재 마우스가 올라간 코드 블록의 텍스트 전체를 `navigator.clipboard.writeText`를 이용해 클립보드에 복사해줘요. + +----- + +### 3\. FloatingLinkEditorPlugin: 똑똑한 링크 편집기 + +**파일**: `FloatingLinkEditorPlugin.tsx` +**역할**: 링크가 걸린 텍스트를 클릭하면 그 주변에 작은 창(플로팅 에디터)을 띄워서 링크를 바로 수정하거나 삭제할 수 있게 해줘요. + +#### 어떻게 작동하나요? + +**1. 링크 상태 감지 및 위치 계산** +`updateToolbar` 함수는 현재 선택된 텍스트가 링크인지 아닌지를 판단하고, `updateLinkEditor` 함수는 선택된 텍스트의 화면상 좌표를 계산해서 플로팅 에디터가 어디에 나타나야 할지 정해요. + +```typescript +const updateLinkEditor = useCallback(() => { + const selection = $getSelection(); + if (...) { + // 선택 영역의 DOM 위치를 가져와서 + const domRect = nativeSelection.focusNode?.parentElement?.getBoundingClientRect(); + if (domRect) { + // 플로팅 UI 위치를 계산하고 업데이트! + setFloatingElemPositionForLinkEditor(...); + } + } +}, [editor, ...]); +``` + +**2. URL 자동 감지 및 생성** +이 플러그인의 숨겨진 꿀 기능\! 텍스트를 선택한 상태에서 유효한 URL(`https://...`)을 붙여넣으면, 자동으로 그 텍스트에 링크를 걸어줘요. + +----- + +### 4\. ImagePlugin: 이미지 삽입의 해결사 + +**파일**: `ImagePlugin.tsx` +**역할**: `INSERT_IMAGE_COMMAND`라는 명령어를 받아서, 에디터에 실제 `ImageNode`를 삽입하는 단순하지만 중요한 역할을 해요. + +#### 어떻게 작동하나요? + +이 플러그인은 딱 한 가지 일만 해요. `INSERT_IMAGE_COMMAND` 요청이 오면 페이로드로 받은 이미지 정보(`src`, `altText` 등)를 이용해 `$createImageNode` 함수로 이미지 노드를 만들고, `$insertNodes`로 에디터에 삽입하죠. + +```typescript +editor.registerCommand( + INSERT_IMAGE_COMMAND, + (payload) => { + // 1. 페이로드로 이미지 노드 생성 + const imgNode = $createImageNode(payload); + // 2. 에디터에 노드 삽입 + $insertNodes([imgNode]); + + // 3. 이미지가 최상단에 덩그러니 있으면 문단(p)으로 감싸주는 센스! + if ($isRootOrShadowRoot(imgNode.getParentOrThrow())) { + $wrapNodeInElement(imgNode, $createParagraphNode).selectEnd(); + } + + return true; + }, + COMMAND_PRIORITY_EDITOR +) +``` + +----- + +### 5\. DraggablePlugin: 블록을 자유롭게 이동 + +**파일**: `DraggablePlugin.tsx` +**역할**: 문단, 이미지, 코드 블록 등 각 콘텐츠 블록을 마우스로 끌어서 순서를 바꿀 수 있게 해줘요. + +#### 어떻게 작동하나요? (핵심만\!) + +**1. 빠른 블록 탐색 (이진 탐색)** +마우스가 어떤 블록 위에 있는지 찾기 위해 모든 블록을 하나씩 다 검사하면 비효율적이겠죠? 그래서 **이진 탐색(Binary Search)** 알고리즘을 사용해서 아주 빠르게(시간 복잡도 O(log n)) 해당 블록을 찾아내요. + +**2. 드래그 앤 드롭 로직** +드래그가 시작되면(`onDragStart`) 어떤 노드가 끌리고 있는지 기록하고, 다른 블록 위로 지나갈 때(`onDragOver`)는 어디에 놓을 수 있는지 시각적으로 선을 보여줘요. 마지막으로 마우스를 놓으면(`onDrop`) 기록해둔 노드를 해당 위치로 실제로 이동시켜요. + +**3. 시각적 피드백** +유저가 블록을 끌고 다른 블록 위를 지날 때, 블록의 위 또는 아래에 파란 선을 그려줘서 "여기에 놓을 수 있어요\!"라고 알려줘요. 사용자 경험을 높이는 중요한 디테일이죠. + +----- + +### 6\. ClipboardPlugin: 스마트한 복사/붙여넣기 + +**파일**: `ClipboardPlugin.ts` +**역할**: 단순한 텍스트 복붙을 넘어, 코드 조각을 자동으로 감지하고, Lexical 에디터 간에는 서식까지 완벽하게 복사해주는 똑똑한 클립보드예요. + +#### 어떻게 작동하나요? + +**1. 코드 자동 감지** +일반 텍스트를 붙여넣을 때, 내용에 `function`, `const` 같은 키워드가 많거나 들여쓰기가 많으면 "어, 이거 코드 같은데?" 하고 추측해요. + +```typescript +const looksLikeCode = (text: string): boolean => { + // ... 키워드 개수, 들여쓰기 비율 등을 바탕으로 휴리스틱 분석 + return (keywordCnt > 0 && indentCnt >= lines.length / 4 && ...); +}; +``` + +코드로 판단되면, Prism.js를 이용해 어떤 프로그래밍 언어인지까지 분석해서 자동으로 코드 블록을 만들어줘요. 정말 편리하죠\! + +**2. 풍부한 클립보드 데이터** +에디터 내용을 복사할 때, 클립보드에 여러 형식의 데이터를 한꺼번에 저장해요. + +1. **Lexical 내부 형식**: 다른 Lexical 에디터에 붙여넣을 때 서식을 완벽하게 유지해요. +2. **HTML 형식**: Notion이나 다른 웹 에디터에 붙여넣어도 스타일이 어느 정도 유지돼요. +3. **일반 텍스트 형식**: 메모장 같은 곳에 붙여넣을 때를 대비해요. + +**3. 붙여넣기 우선순위 처리** +붙여넣기를 할 때는 위 데이터 형식을 역순으로 확인해서 가장 풍부한 정보를 가진 데이터부터 처리하려고 시도해요. + +----- + +### 7\. GrabContentPlugin: 실시간 내용 전달자 + +**파일**: `GrabContentPlugin.tsx` +**역할**: 에디터 내용이 바뀔 때마다 그 내용을 실시간으로 HTML 문자열로 변환해서 부모 컴포넌트(`PostEditor`)로 전달하는 '보고' 역할을 해요. + +#### 어떻게 작동하나요? + +`editor.registerUpdateListener`를 사용해 에디터에 작은 변화라도 생길 때마다 콜백 함수가 실행되게 해요. 콜백 안에서는 `$generateHtmlFromNodes` 유틸리티를 사용해 현재 에디터 상태를 HTML로 변환하고, 그 결과를 `forwardContent` prop으로 쏴주죠. + +----- + +### 8\. HighlightCodePlugin: 코드를 아름답게 + +**파일**: `HighlightCodePlugin.ts` +**역할**: 코드 블록 안의 코드를 언어 문법에 맞게 예쁘게 색칠(하이라이팅)해줘요. + +#### 어떻게 작동하나요? + +이 플러그인은 Lexical이 기본으로 제공하는 `registerCodeHighlighting` 함수를 호출해주는 역할만 해요. 실제 하이라이팅 로직은 Lexical과 Prism.js가 알아서 처리하죠. + +----- + +### 9\. InitContentPlugin: 저장된 글 불러오기 + +**파일**: `InitContentPlugin.tsx` +**역할**: '수정하기' 페이지처럼 기존에 저장된 글(HTML)이 있을 때, 에디터가 처음 로딩될 때 그 내용을 채워 넣어요. + +#### 어떻게 작동하나요? + +`content` prop으로 받은 HTML 문자열을 `parseHtmlStrToLexicalNodes` 유틸리티 함수로 Lexical 노드들로 변환한 다음, `$getRoot().clear().select()`로 에디터를 깨끗이 비우고 변환된 노드들을 삽입해요. `initialized` 상태를 둬서 이 작업이 딱 한 번만 실행되도록 보장하죠. + +----- + +### 10\. MaxIndentPlugin: 들여쓰기 경찰 + +**파일**: `MaxIndentPlugin.tsx` +**역할**: 목록(리스트)에서 너무 깊게 들여쓰기(indent)하는 것을 막아줘요. (기본 6단계) + +#### 어떻게 작동하나요? + +`INDENT_CONTENT_COMMAND`를 \*\*가장 높은 우선순위(`CRITICAL`)\*\*로 가로채요. 그리고 현재 선택된 목록의 깊이를 계산해서, 만약 최대 깊이를 넘으려고 하면 `true`를 반환해서 "이 명령은 무시해\!"라고 알려주죠. + +----- + +### 11\. MarkdownShortcuts: 마크다운 마법사 + +**파일**: `markdownShortcuts.ts` +**역할**: `#`을 입력하면 `h1` 태그로, `**текст**`를 입력하면 굵은 글씨로 바꿔주는 등 익숙한 마크다운 문법을 실시간으로 변환해줘요. + +#### 어떻게 작동하나요? + +Lexical이 제공하는 `@lexical/react/LexicalMarkdownShortcutPlugin`을 사용해요. 우리는 어떤 문법(`regExp`)을 어떤 노드로 바꿀지(`replace`)에 대한 규칙(Transformer) 목록만 정의해주면 돼요. 리스트(` * `)나 코드 블록(\`\`\`\`\`\`)처럼 여러 줄에 걸쳐 있거나 주변 노드와 합쳐져야 하는 복잡한 규칙들도 깔끔하게 처리해요. + +----- + +### 12\. Utils: 우리들의 연장통 + +**파일**: `utils.ts` +**역할**: 여러 플러그인에서 공통으로 사용하는 함수들을 모아놓은 유틸리티 파일이에요. 대표적으로 `parseHtmlStrToLexicalNodes`가 있죠. + +----- + +## 🤝 플러그인들은 어떻게 협업할까? + +### 데이터 흐름 + +유저의 입력은 각자 전문 분야의 플러그인에 의해 처리되고, 커맨드 시스템을 통해 에디터의 핵심 로직으로 전달돼요. 그리고 그 결과는 다시 `GrabContentPlugin`을 통해 외부로 전달되죠. + +```mermaid +graph TD + A[사용자 입력] --> B[ToolbarPlugin] + A --> C[ClipboardPlugin] + A --> D[DraggablePlugin] + + B --> E[FORMAT_TEXT_COMMAND] + C --> F[PASTE_COMMAND] + D --> G[DROP_COMMAND] + + E & F & G --> H[Lexical Core] + + H --> I[GrabContentPlugin] + I --> J[HTML Output] +``` + +### 커맨드 우선순위의 중요성 + +붙여넣기(`PASTE_COMMAND`)처럼 여러 플러그인이 처리하고 싶어 하는 커맨드의 경우, 우선순위가 중요해요. `ClipboardPlugin`은 `CRITICAL` 우선순위로 먼저 붙여넣기 이벤트를 가로채서 코드인지, Lexical 데이터인지 등을 확인하고, 자기가 처리할 수 없으면 `false`를 반환해서 다음 플러그인(`FloatingLinkEditorPlugin`, `LOW` 우선순위)에게 기회를 넘겨요. + +----- + +## 💡 빠릿한 에디터를 만드는 비결 + +PostEditor는 좋은 사용자 경험을 위해 다양한 성능 최적화 기법을 사용해요. + +1. **디바운싱**: 과도한 이벤트 처리를 막아요. (`CodeActionPlugin`) +2. **조건부 리스닝**: 필요할 때만 이벤트를 듣죠. (`CodeActionPlugin`) +3. **메모이제이션 (`useCallback`)**: 불필요한 함수 재생성과 리렌더링을 방지해요. (`ToolbarPlugin`) +4. **포탈 (`Portal`)**: 플로팅 UI를 별도의 DOM 레이어에서 렌더링해서 성능을 최적화해요. (`FloatingLinkEditorPlugin`) + +----- + +## 마무리하며 + +PostEditor의 플러그인 시스템은 **"각자 하나씩, 하지만 함께"** 라는 설계 원칙을 따르고 있어요. + +1. **단일 책임 원칙**: 각 플러그인은 딱 한 가지 기능에만 집중해요. +2. **느슨한 결합**: 커맨드 시스템을 통해 서로 직접 의존하지 않아요. +3. **확장성**: 새로운 기능을 추가하고 싶으면 새 플러그인을 만들기만 하면 돼요. +4. **성능**: 곳곳에 성능 최적화 기법을 녹여냈어요. + +이런 모듈식 아키텍처 덕분에 복잡한 에디터의 기능을 유지보수하기 쉽고, 앞으로 새로운 기능을 추가하기도 용이한 튼튼한 구조를 갖추게 되었습니다. \ No newline at end of file diff --git a/.docs/editor/PostEditor Segments.md b/.docs/editor/PostEditor Segments.md new file mode 100644 index 00000000..e1e98279 --- /dev/null +++ b/.docs/editor/PostEditor Segments.md @@ -0,0 +1,269 @@ + +----- + +# PostEditor 툴바 조립하기: Segments 컴포넌트 시스템 🛠️ + +## 시작하며 + +PostEditor의 심장부가 '플러그인'이라면, 유저와 직접 만나는 얼굴은 '툴바'겠죠. 이 툴바는 어떻게 만들어져 있을까요? 거대한 하나의 컴포넌트가 아니라, 작고 전문화된 UI 조각들, 즉 **세그먼트(Segment) 컴포넌트**들을 조립해서 만들었습니다. + +`segments` 디렉토리에는 바로 이 UI 레고 블록들이 들어있어요. 폰트 설정, 이미지 추가 버튼 등 각자의 역할이 명확한 재사용 가능한 컴포넌트들이죠. 이번 문서에서는 이 세그먼트들이 어떻게 설계되었고, 서로 어떻게 맞물려 돌아가는지 알아보겠습니다. + +----- + +## 목차 + +- [어떤 생각으로 만들었을까? (설계 철학)](https://www.google.com/search?q=%23%EC%96%B4%EB%96%A4-%EC%83%9D%EA%B0%81%EC%9C%BC%EB%A1%9C-%EB%A7%8C%EB%93%A4%EC%97%88%EC%9D%84%EA%B9%8C-%EC%84%A4%EA%B3%84-%EC%B2%A0%ED%95%99) +- [툴바의 부품들, 하나씩 살펴보기](https://www.google.com/search?q=%23%ED%88%B4%EB%B0%94%EC%9D%98-%EB%B6%80%ED%92%88%EB%93%A4-%ED%95%98%EB%82%98%EC%94%A9-%EC%82%B4%ED%8E%B4%EB%B3%B4%EA%B8%B0) + - [1. FontDropdown: 폰트 꾸미기의 모든 것](https://www.google.com/search?q=%231-fontdropdown-%ED%8F%B0%ED%8A%B8-%EA%BE%B8%EB%AF%B8%EA%B8%B0%EC%9D%98-%EB%AA%A8%EB%93%A0-%EA%B2%83) + - [2. InsertImageButton: 이미지 추가의 시작점](https://www.google.com/search?q=%232-insertimagebutton-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%B6%94%EA%B0%80%EC%9D%98-%EC%8B%9C%EC%9E%91%EC%A0%90) + - [3. ImageInputBox: 똑똑한 이미지 업로더](https://www.google.com/search?q=%233-imageinputbox-%EB%98%91%EB%98%91%ED%95%9C-%EC%9D%B4%EB%AF%B8%EC%A7%80-%EC%97%85%EB%A1%9C%EB%8D%94) + - [4. ToggleButton: 만능 스위치 (예정)](https://www.google.com/search?q=%234-togglebutton-%EB%A7%8C%EB%8A%A5-%EC%8A%A4%EC%9C%84%EC%B9%98-%EC%98%88%EC%A0%95) +- [좋은 UI를 만드는 우리만의 원칙](https://www.google.com/search?q=%23%EC%A2%8B%EC%9D%80-ui%EB%A5%BC-%EB%A7%8C%EB%93%9C%EB%8A%94-%EC%9A%B0%EB%A6%AC%EB%A7%8C%EC%9D%98-%EC%9B%90%EC%B9%99) +- [사용자 경험 시나리오 (상호 작용)](https://www.google.com/search?q=%23%EC%82%AC%EC%9A%A9%EC%9E%90-%EA%B2%BD%ED%97%98-%EC%8B%9C%EB%82%98%EB%A6%AC%EC%98%A4-%EC%83%81%ED%98%B8-%EC%9E%91%EC%9A%A9) +- [빠릿한 UI를 위한 노력들](https://www.google.com/search?q=%23%EB%B9%A0%EB%A6%BF%ED%95%9C-ui%EB%A5%BC-%EC%9C%84%ED%95%9C-%EB%85%B8%EB%A0%A5%EB%93%A4) +- [미래를 생각하는 설계](https://www.google.com/search?q=%23%EB%AF%B8%EB%9E%98%EB%A5%BC-%EC%83%9D%EA%B0%81%ED%95%98%EB%8A%94-%EC%84%A4%EA%B3%84) + +----- + +## 🤔 어떤 생각으로 만들었을까? (설계 철학) + +세그먼트 컴포넌트들의 핵심 설계 철학은 \*\*"하나의 컴포넌트는 한 가지 일만 잘하자"\*\*는 \*\*단일 책임 원칙(SRP)\*\*입니다. 툴바라는 큰 기능을 잘게 쪼개서 각 부품이 자신의 역할에만 집중하게 만들었죠. + +```mermaid +graph TB + A[ToolbarPlugin] --> B[FontDropdown] + A --> C[InsertImageButton] + A --> D[기타 UI 컴포넌트들] + + C --> E[ImageInputBox] + + B --> F[폰트 종류 선택] + B --> G[폰트 색상 선택] + B --> H[폰트 크기 선택] + B --> I[글자 서식 변경] + + E --> J[파일 올리기] + E --> K[URL로 넣기] + E --> L[대체 텍스트 입력] +``` + +이렇게 하면 `FontDropdown`은 폰트 걱정만, `InsertImageButton`은 이미지 추가 걱정만 하면 되니 코드가 훨씬 깔끔해지고 관리하기도 쉬워져요. + +----- + +## 🔩 툴바의 부품들, 하나씩 살펴보기 + +### 1\. FontDropdown: 폰트 꾸미기의 모든 것 + +**파일**: `FontDropdown.tsx` +**역할**: 폰트 종류, 크기, 색상, 스타일 등 글자를 꾸미는 데 필요한 모든 기능을 한데 모아놓은 컨트롤 타워예요. + +#### 속 들여다보기 + +**1. 다양한 폰트 옵션 제공** +유저가 고를 수 있는 폰트 목록과 색상 팔레트를 미리 정의해두고, 선택된 옵션을 화면에 보여줘요. 한글 폰트 이름('프리텐다드')을 내부적으로는 영문('Pretendard')으로 처리하는 작은 디테일도 숨어있죠. + +```typescript +// 유저에게 보여줄 폰트 목록 +const FONT_FAMILY_OPTIONS: [string, string][] = [ + ['Pretendard', '프리텐다드'], // [CSS 값, 표시 이름] + ['Arial', 'Arial'], +]; + +// 유저에게 보여줄 색상 목록 +const FONT_COLOR_OPTIONS: string[] = [ '#000', '#3C42E0', ... ]; +``` + +**2. 직관적인 텍스트 서식 버튼** +'굵게', '기울임' 같은 서식 버튼들은 현재 텍스트 상태에 따라 활성화 여부(`isHighlighted`)가 바뀌어요. 상위/하위 첨자처럼 아이콘으로 표현하기 어려운 기능은 재치있게 텍스트로 구현했죠. + +```typescript + + B + +``` + +**3. Lexical과 소통하기** +유저가 폰트 크기나 색상을 바꾸면, `editor.update` 안에서 Lexical이 제공하는 `$patchStyleText` API를 호출해 에디터에 즉시 스타일을 적용해요. + +```typescript +const onClick = useCallback((style: string, option: string) => { + editor.update(() => { + const selection = $getSelection(); + if (selection !== null) { + // 선택된 텍스트에 스타일을 입혀줘! + $patchStyleText(selection, { [style]: option }); + } + }); + }, [editor]); +``` + +> **왜 만들었을까?** 흩어지기 쉬운 폰트 관련 기능들을 한곳에 모아 사용자에게 통합된 경험을 주고, 모바일에서도 쓰기 편하도록 반응형으로 설계했어요. + +----- + +### 2\. InsertImageButton: 이미지 추가의 시작점 + +**파일**: `InsertImageButton.tsx` +**역할**: 이미지 아이콘 버튼 그 자체와, 버튼을 눌렀을 때 나타나는 이미지 입력창(`ImageInputBox`)을 띄우고 관리하는 역할을 해요. + +#### 속 들여다보기 + +**1. React Portal로 z-index 전쟁 종식\!** +이미지 입력창 같은 모달(modal)은 종종 다른 UI에 가려지는 `z-index` 문제가 생기곤 하죠. `createPortal`을 사용해 모달을 DOM 트리의 최상단(`document.body`)으로 순간이동시켜서 이런 문제를 원천 봉쇄했어요. + +```typescript +return ( + <> + + {showImageInsertBox && + createPortal( // ImageInputBox를 document.body로 텔레포트! + , + document.body + )} + +); +``` + +**2. 모달 위치는 동적으로 계산** +버튼을 기준으로 모달의 위치(`top`, `left`)를 동적으로 계산해서, 화면 어디에 있든 항상 버튼 바로 아래에 예쁘게 나타나도록 했어요. + +**3. 바깥을 누르면 닫히는 편리함** +모달이 열렸을 때 `document`에 클릭 이벤트를 걸어두고, 만약 클릭된 곳이 모달 영역 바깥이라면 모달을 닫아주는 흔하지만 아주 편리한 UX를 구현했어요. + +> **왜 만들었을까?** 이미지 추가 기능의 '관문' 역할을 하면서, 모달 관리의 복잡한 로직(위치 계산, 외부 클릭 감지, Portal 렌더링)을 도맡아 처리해요. 덕분에 `ToolbarPlugin`은 "이 버튼 그냥 보여줘" 하기만 하면 되죠. + +----- + +### 3\. ImageInputBox: 똑똑한 이미지 업로더 + +**파일**: `ImageInputBox.tsx` +**역할**: `InsertImageButton`이 띄워주는 바로 그 모달창. 유저가 다양한 방식으로 이미지를 올리고, 설정을 마칠 수 있는 종합 인터페이스예요. + +#### 속 들여다보기 + +**1. 세 가지 업로드 방식 지원** +사용자 편의를 위해 파일 직접 선택, URL 입력, 드래그 앤 드롭까지 세 가지 이미지 입력 방식을 모두 지원해요. 하나의 방식이 활성화되면 다른 방식은 비활성화해서 혼동을 줄였죠. + +**2. 안전한 S3 업로드 (Presigned URL)** +이미지를 우리 서버를 거쳐 S3에 올리면 비효율적이고 보안에도 좋지 않아요. 그래서 **Presigned URL** 방식을 사용해요. + +1. 우리 서버에 "이 파일 S3에 올려도 돼?" 하고 물어보면, +2. 서버가 "응, 이 티켓(Presigned URL) 가지고 10분 안에 직접 올려." 하고 1회용 업로드 티켓을 줘요. +3. 브라우저는 그 티켓을 가지고 S3에 직접 파일을 안전하게 업로드해요. + + + +```typescript +const uploadToS3 = async (file: File) => { + // 1. 우리 서버에 1회용 업로드 티켓(Presigned URL) 요청 + const data = await requestPresignedUrl(...); + // 2. 받은 티켓으로 S3에 직접 파일 업로드 + await toS3({ url: data.presignedUrl, ... }); + return data.presignedUrl; +}; +``` + +> **왜 만들었을까?** 복잡한 이미지 업로드 과정을 하나의 컴포넌트에 캡슐화했어요. 파일 상태 관리, UI 피드백, 안전한 S3 통신까지 이미지 업로드에 관한 모든 책임을 이 컴포넌트가 짊어집니다. + +----- + +### 4\. ToggleButton: 만능 스위치 (예정) + +**파일**: `ToggleButton.tsx` +**역할**: 켜고 끄는 기능이 필요한 모든 곳에 쓸 수 있는 범용 토글 버튼이에요. (현재는 비어있지만, 이렇게 만들 수 있겠죠?) + +#### 미래의 모습 + +```typescript +// 이런 모습으로 완성될 수 있어요 +export const ToggleButton: React.FC<{ + isToggled: boolean; // 켜졌는지? + onToggle: () => void; // 누르면 뭘 할지? + children: ReactNode; // 버튼 안에 들어갈 내용 +}> = ({ isToggled, onToggle, children }) => { + return ( + + {children} + + ); +}; +``` + +> **왜 만들까?** 에디터 곳곳에 필요한 간단한 'ON/OFF' 스위치 역할을 할 재사용성 높은 부품으로 설계될 예정이에요. + +----- + +## ✨ 좋은 UI를 만드는 우리만의 원칙 + +### 1\. 작은 블록으로 조립하기 (컴포지션) + +큰 `ToolbarPlugin`을 `FontDropdown`, `InsertImageButton` 같은 작은 부품으로 나눠 조립했어요. 이렇게 하면 각 부품을 독립적으로 수정하거나 교체하기 쉬워져요. + +### 2\. 부모가 시키는 대로 (제어 역전, IoC) + +`InsertImageButton`은 이미지를 어떻게 에디터에 넣을지 몰라요. 그저 부모(`ToolbarPlugin`)가 "이미지 준비되면 이 함수(`insertImage`) 실행해\!" 하고 넘겨준 일을 할 뿐이죠. 이렇게 역할을 분리하면 `InsertImageButton`은 어떤 부모 밑에서도 일할 수 있는 재사용성 높은 부품이 돼요. + +----- + +## 💬 사용자 경험 시나리오 (상호 작용) + +### 유저가 이미지를 추가하는 과정 + +```mermaid +sequenceDiagram + participant U as 사용자 + participant IB as InsertImageButton + participant IIB as ImageInputBox + participant S3 as S3 저장소 + participant E as 에디터 + + U->>IB: 이미지 아이콘 클릭 + IB->>IIB: "나타나!" (모달 표시) + U->>IIB: 컴퓨터에서 파일을 끌어다 놓는다 + IIB->>IIB: (내부적으로) 파일 상태 업데이트 + U->>IIB: "추가하기" 버튼 클릭 + IIB->>S3: "업로드 티켓 주세요" (Presigned URL 요청) + S3-->>IIB: "여기 티켓!" + IIB->>S3: 티켓과 함께 파일 업로드 + S3-->>IIB: "업로드 완료!" + IIB->>E: "이 이미지 에디터에 넣어줘!" (COMMAND 발행) + E->>U: 화면에 이미지가 짠! + IIB->>IB: "임무 완료!" (모달 닫기) +``` + +----- + +## 🚀 빠릿한 UI를 위한 노력들 + +* **기억하기 (`useCallback`)**: 불필요한 함수 생성을 막아 렌더링 성능을 아껴요. +* **필요할 때만 그리기 (조건부 렌더링)**: 이미지 입력창은 버튼을 눌렀을 때만 렌더링해서 초기 로딩 부담을 줄여요. +* **깔끔한 뒷정리**: 모달이 닫힐 때 이벤트 리스너를 깨끗하게 제거해서 메모리 누수를 막아요. + +----- + +## 🌐 미래를 생각하는 설계 + +### 1\. 유연한 인터페이스 (Props) + +`InsertImageButton`의 `buttonLabel` prop에 텍스트를 주든, 아이콘 컴포넌트를 주든 뭐든지 받을 수 있게 `ReactNode` 타입으로 만들었어요. 덕분에 다양한 모습으로 재사용할 수 있죠. + +### 2\. 새로운 블록 추가하기 + +만약 툴바에 '비디오 추가'나 '표 추가' 버튼이 필요해진다면? `InsertVideoButton` 같은 새로운 세그먼트 컴포넌트를 만들어 `ToolbarPlugin`에 쏙 끼워넣기만 하면 돼요. 기존 코드는 거의 건드릴 필요가 없죠. + +----- + +## 결론 + +PostEditor의 `segments`는 \*\*"복잡한 UI를 작고, 독립적이고, 재사용 가능한 부품들로 조립한다"\*\*는 생각으로 설계되었어요. + +* **하나의 부품은 하나의 기능만** (단일 책임) +* **부품을 조립해 완성품을** (컴포지션) +* **부품은 부모가 시키는 일만** (제어 역전) + +이런 원칙들 덕분에 PostEditor의 UI는 일관성을 유지하면서도, 앞으로 새로운 기능을 추가하거나 기존 기능을 수정하기 아주 좋은 유연한 구조를 갖게 되었습니다. 결국 이 작은 세그먼트들이 모여 PostEditor의 전체적인 **사용자 경험**을 완성하는 셈이죠. \ No newline at end of file diff --git a/.docs/editor/PostEditor.md b/.docs/editor/PostEditor.md new file mode 100644 index 00000000..69fcc43b --- /dev/null +++ b/.docs/editor/PostEditor.md @@ -0,0 +1,540 @@ + +----- + +# PostEditor.tsx 에디터 사용 설명서 📝 + +## 간단한 소개 + +**PostEditor**는 저희 코몬(Comon) 프로젝트에서 사용하는 **Lexical 기반의 리치 텍스트 에디터**예요. 게시글을 쓸 때 필요한 기능들을 모아 하나의 컴포넌트로 만들었죠. 마크다운 문법이나 코드 하이라이팅은 물론, 이미지 드래그 앤 드롭 같은 편리한 기능들도 담겨있습니다. + +----- + +## 목차 + +- [핵심 개념](https://www.google.com/search?q=%23-%ED%95%B5%EC%8B%AC-%EA%B0%9C%EB%85%90) +- [전체적인 구조](https://www.google.com/search?q=%23-%EC%A0%84%EC%B2%B4%EC%A0%81%EC%9D%B8-%EA%B5%AC%EC%A1%B0) +- [주요 기능들](https://www.google.com/search?q=%23-%EC%A3%BC%EC%9A%94-%EA%B8%B0%EB%8A%A5%EB%93%A4) +- [어떻게 쓰나요?](https://www.google.com/search?q=%23-%EC%96%B4%EB%96%BB%EA%B2%8C-%EC%93%B0%EB%82%98%EC%9A%94) +- [플러그인 시스템](https://www.google.com/search?q=%23-%ED%94%8C%EB%9F%AC%EA%B7%B8%EC%9D%B8-%EC%8B%9C%EC%8A%A4%ED%85%9C) +- [입맛대로 바꾸기 (커스터마이징)](https://www.google.com/search?q=%23-%EC%9E%85%EB%A7%9B%EB%8C%80%EB%A1%9C-%EB%B0%94%EA%BE%B8%EA%B8%B0-%EC%BB%A4%EC%8A%A4%ED%84%B0%EB%A7%88%EC%9D%B4%EC%A7%95) +- [API 레퍼런스](https://www.google.com/search?q=%23api-%EB%A0%88%ED%8D%BC%EB%9F%B0%EC%8A%A4) + +----- + +## 💡 핵심 개념 + +### Lexical 프레임워크 + +PostEditor는 Meta(구 Facebook)에서 만든 **Lexical**이라는 프레임워크를 기반으로 만들었어요. Lexical은 이런 특징들이 있어서 선택하게 됐습니다. + +* **불변성(Immutability)**: 에디터의 상태(state)가 불변 객체로 관리되어서 안정적이에요. +* **플러그인 아키텍처**: 기능들을 독립된 플러그인으로 만들어 쉽게 붙였다 뗄 수 있어요. +* **타입 안전성**: TypeScript로 만들어져서 타입을 명확하게 관리할 수 있어요. +* **접근성**: 웹 접근성 표준(WAI-ARIA)을 잘 지켜서 만들어졌어요. + +### 에디터의 기본 재료들 + +에디터를 시작할 때 필요한 기본 설정과 노드(Node)들이에요. 이미지, 링크, 제목, 코드 블록 등 에디터가 이해할 수 있는 콘텐츠의 종류를 미리 알려주는 거죠. + +```typescript +// 에디터 초기 설정 +const initialConfig = { + namespace: 'comon', + theme: editorTheme, + nodes: [ + ImageNode, // 이미지 + AutoLinkNode, // 자동 링크 + LinkNode, // 링크 + HeadingNode, // 제목 (h1, h2...) + QuoteNode, // 인용문 + ListNode, // 리스트 (ul) + ListItemNode, // 리스트 아이템 (li) + CodeNode, // 코드 블록 + CodeHighlightNode, // 코드 하이라이팅 + ], + onError, +}; +``` + +----- + +## 🏗️ 전체적인 구조 + +### 컴포넌트 구조 + +PostEditor는 여러 컴포넌트가 조립된 형태로 되어있어요. + +``` +PostEditor +├── LexicalComposer // 에디터의 핵심 로직을 관리해요. +├── PostSectionWrap // 드래그 앤 드롭 기능을 담당하는 래퍼예요. +│ ├── TitleInput // 제목을 입력하는 부분 +│ ├── ToolbarPlugin // 글자 스타일을 바꾸는 툴바 +│ └── PostWriteSection // 글을 실제로 작성하는 영역 +│ ├── RichTextPlugin // 기본적인 텍스트 기능을 제공해요. +│ ├── 여러 플러그인들... +│ └── ContentEditable // 편집 가능한 실제 DOM 영역 +``` + +### 플러그인 구조 + +에디터의 주요 기능들은 대부분 플러그인으로 만들어져 있어요. 필요한 기능을 쉽게 추가하거나 뺄 수 있죠. + +| 플러그인 이름 | 하는 일 | 파일 위치 | +| :--- | :--- | :--- | +| **ToolbarPlugin** | 텍스트 서식을 바꾸는 툴바 기능 | `plugins/ToolbarPlugin.tsx` | +| **ImagePlugin** | 이미지 추가하고 관리하는 기능 | `plugins/ImagePlugin.tsx` | +| **CodeActionPlugin** | 코드 블록 관련 기능들 | `plugins/CodeActionPlugin.tsx` | +| **FloatingLinkEditorPlugin** | 링크를 수정할 때 뜨는 작은 창 | `plugins/FloatingLinkEditorPlugin.tsx` | +| **DraggablePlugin** | 콘텐츠 블록을 끌어서 옮기는 기능 | `plugins/DraggablePlugin.tsx` | +| **GrabContentPlugin** | 작성된 콘텐츠를 가져오는 역할 | `plugins/GrabContentPlugin.tsx` | +| **HighlightCodePlugin** | 코드를 예쁘게 색칠해주는 기능 | `plugins/HighlightCodePlugin.ts` | +| **ClipboardPlugin** | 복사/붙여넣기 처리 | `plugins/ClipboardPlugin.ts` | +| **InitContentPlugin** | 처음 에디터에 보여줄 내용 설정 | `plugins/InitContentPlugin.tsx` | +| **MaxIndentPlugin** | 글머리 기호 들여쓰기 제한 | `plugins/MaxIndentPlugin.tsx` | + +----- + +## ✨ 주요 기능들 + +### 1\. 텍스트 꾸미기 + +글을 꾸밀 수 있는 다양한 서식 기능이 있어요. + +* **굵게**: `Ctrl+B` 또는 `**텍스트**` +* *기울임*: `Ctrl+I` 또는 `*텍스트*` +* \~\~취소선\~\~: `~~텍스트~~` +* `인라인 코드`: `` `코드` `` +* 위첨자 및 아래첨자 + +### 2\. 마크다운 단축키 + +익숙한 마크다운 문법도 대부분 쓸 수 있어요. + +````markdown +# 제목 1 +## 제목 2 +### 제목 3 + +- 순서 없는 목록 +1. 순서 있는 목록 + +> 인용문 + +```javascript +// 코드 블록 +console.log("Hello World"); +```` + +```` + +### 3. 이미지 다루기 + +#### 이미지는 이렇게 올릴 수 있어요 + +1. **드래그 앤 드롭**: 컴퓨터에 있는 이미지를 에디터로 끌어다 놓으세요. +2. **붙여넣기**: 웹서핑하다 본 이미지를 복사해서 `Ctrl+V`로 붙여넣을 수 있어요. +3. **툴바 버튼**: 툴바의 이미지 아이콘을 눌러 직접 파일을 선택할 수도 있습니다. + +#### 이미지 업로드 과정 + +이미지가 올라가면 내부적으로는 이런 과정을 거쳐요. + +```typescript +// 이미지 업로드 처리 과정 +1. 이미지 파일이 맞는지 확인 +2. 서버에 Presigned URL 요청 (S3에 올릴 권한 받기) +3. S3(저장소)에 이미지 업로드 +4. 에디터 본문에 이미지 삽입 +```` + +### 4\. 코드 하이라이팅 + +Prism.js를 활용해서 다양한 프로그래밍 언어의 코드를 예쁘게 보여줘요. + +```typescript +// 테마에 정의된 하이라이팅 색상들 +const editorTheme: EditorThemeClasses = { + codeHighlight: { + atrule: 'o', // @-규칙 + attr: 'o', // 속성 + boolean: 'p', // 불리언 + comment: 'r', // 주석 + function: 's', // 함수 + keyword: 'o', // 키워드 + number: 'p', // 숫자 + operator: 't', // 연산자 + string: 'q', // 문자열 + variable: 'u', // 변수 + }, +}; +``` + +----- + +## 🚀 어떻게 쓰나요? + +### 기본 사용법 + +`PostEditor` 컴포넌트를 불러와서 필요한 props만 넘겨주면 바로 쓸 수 있어요. + +```typescript +import PostEditor from '@/components/features/Post/PostEditor'; + +function MyComponent() { + const [title, setTitle] = useState(''); + const [content, setContent] = useState(''); + + return ( + + ); +} +``` + +### Props 살펴보기 + +| Prop | 타입 | 필수 | 설명 | +| :--- | :--- | :--- | :--- | +| `imageCategory` | `string` | ✅ | S3에 이미지를 올릴 때 사용할 카테고리예요. (e.g., 'posts', 'profile') | +| `forwardTitle` | `(title: string) => void` | ❌ | 제목이 바뀔 때마다 부모 컴포넌트로 값을 보내줘요. | +| `forwardContent` | `(content: string) => void` | ❌ | 내용이 바뀔 때마다 부모 컴포넌트로 값을 보내줘요. | +| `content` | `string` | ❌ | 에디터에 처음 보여줄 초기 콘텐츠 내용이에요. | +| `title` | `string` | ❌ | 에디터에 처음 보여줄 초기 제목이에요. | +| `tag` | `string` | ❌ | 선택된 게시글 태그 | +| `setTag` | `(tag: string) => void` | ❌ | 태그를 변경할 때 사용하는 함수 | + +### 고급 활용법 + +#### 1\. 이미지 처리 로직 직접 만들기 + +기본 이미지 업로드 로직 대신, 직접 만든 함수를 사용하고 싶을 때가 있죠. 예를 들어, 이미지를 압축해서 올리고 싶을 때 활용할 수 있어요. + +```typescript +// 이미지 업로드 과정을 직접 만들어봅시다. +const customImageHandler = async (file: File) => { + // 1. 이미지 파일인지 확인 + if (!file.type.startsWith('image/')) { + throw new Error('이미지 파일만 올릴 수 있어요!'); + } + + // 2. 이미지 압축 (선택 사항) + const compressedFile = await compressImage(file); + + // 3. S3에 업로드 + const uploadResult = await uploadToS3(compressedFile); + + // 4. 에디터에 삽입할 이미지 정보 반환 + const imgPayload: InsertImagePayload = { + altText: '업로드된 이미지', + maxWidth: 600, + src: uploadResult.url, + }; + + return imgPayload; +}; +``` + +#### 2\. 작성된 콘텐츠 가져오기 + +작성된 글을 HTML이나 텍스트로 가져와서 저장하거나 다른 곳에 활용할 수 있어요. + +```typescript +// 에디터 내용을 HTML로 가져오기 +const extractHTMLContent = (editor: LexicalEditor): string => { + let htmlContent = ''; + + editor.update(() => { + const root = $getRoot(); + // 실제로는 $generateHtmlFromNodes(editor, selection) 같은 함수를 사용해요. + // 이 코드는 예시를 위해 단순화했습니다. + htmlContent = root.getTextContent(); + }); + + return htmlContent; +}; +``` + +----- + +## 🧩 플러그인 시스템 + +### 나만의 플러그인 만들기 + +새로운 기능을 추가하고 싶다면, 직접 플러그인을 만들어보세요. 아래 구조를 따르면 쉽게 만들 수 있어요. + +```typescript +// 예시: CustomPlugin.tsx +import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; +import { useEffect } from 'react'; + +export function CustomPlugin() { + const [editor] = useLexicalComposerContext(); + + useEffect(() => { + // 플러그인이 처음 로드될 때 실행할 로직 + const cleanup = editor.registerCommand( + CUSTOM_COMMAND, + (payload) => { + // 커맨드가 실행됐을 때 처리할 로직 + return true; // 이벤트가 처리됐다고 알려줌 + }, + COMMAND_PRIORITY_LOW // 커맨드 처리 우선순위 + ); + + // 컴포넌트가 사라질 때 정리 + return cleanup; + }, [editor]); + + return null; // 별도의 UI가 없는 플러그인은 null을 반환 +} +``` + +### 주요 플러그인 톺아보기 + +#### ToolbarPlugin + +글씨체를 바꾸거나 링크를 다는 등 텍스트 서식을 담당하는 툴바예요. + +* **주요 기능**: 굵게, 기울임, 취소선, 폰트 종류/크기 변경, 링크/이미지 추가, 태그 선택 등 +* **사용법**: + + + +```typescript + +``` + +#### ImagePlugin + +에디터의 모든 이미지 처리를 담당해요. + +* **주요 기능**: 이미지 추가 커맨드 처리, 이미지 크기 조절, 정렬 등 + +#### DraggablePlugin + +콘텐츠 블록(문단, 이미지 등)을 마우스로 끌어서 순서를 바꿀 수 있게 해줘요. + +* **주요 기능**: 블록 옆에 드래그 핸들 표시, 드래그해서 순서 바꾸기, 어디에 놓을지 시각적으로 표시 + +----- + +## 🎨 입맛대로 바꾸기 (커스터마이징) + +### 테마 수정하기 + +에디터의 색상이나 폰트 스타일을 바꾸고 싶다면 `editorTheme` 객체를 수정하면 돼요. + +```typescript +const customTheme: EditorThemeClasses = { + ...editorTheme, // 기존 테마를 복사하고 + text: { + ...editorTheme.text, + bold: 'my-custom-bold-class', // 굵게 스타일에 적용할 CSS 클래스 이름 변경 + italic: 'my-custom-italic-class', + }, + // 다른 스타일도 자유롭게 바꿀 수 있어요. +}; +``` + +### CSS 직접 수정하기 + +더 세밀한 디자인 변경이 필요하면 `editor.css` 파일을 직접 수정하세요. + +```css +/* 에디터 기본 스타일 */ +.content-editable { + outline: none; + font-family: Pretendard, serif; + line-height: 1.5; + padding: 20px 50px; +} + +/* 나만의 스타일 추가하기 */ +.my-custom-bold-class { + font-weight: 900; + color: #ff6b6b; /* 굵은 글씨를 더 굵고 빨갛게! */ +} +``` + +### 반응형 디자인 + +에디터는 모바일 화면도 고려해서 만들어졌어요. `max-width` 미디어 쿼리를 사용해서 모바일 스타일을 조정할 수 있습니다. + +```css +@media (max-width: 768px) { + .content-editable { + padding: 20px 20px; /* 모바일에선 여백을 줄여요 */ + } + + .editor-dropdown-items { + font-size: 12px; /* 드롭다운 폰트 크기 조정 */ + } +} +``` + +----- + +## 📚 API 레퍼런스 + +### 주요 Commands + +에디터의 특정 동작을 실행시키는 명령어들이에요. + +| 커맨드 | 설명 | 페이로드 타입 | +| :--- | :--- | :--- | +| `INSERT_IMAGE_COMMAND` | 에디터에 이미지를 넣어요. | `InsertImagePayload` | +| `TOGGLE_LINK_COMMAND` | 선택된 텍스트에 링크를 걸거나 해제해요. | `string \| null` | +| `FORMAT_TEXT_COMMAND` | 텍스트 서식을 적용해요 (굵게, 기울임 등). | `TextFormatType` | + +### 타입 정의 + +`PostEditor`에서 사용하는 주요 타입들이에요. + +```typescript +// 이미지 삽입 시 필요한 정보 +interface InsertImagePayload { + altText: string; + maxWidth: number; + src: string; +} + +// PostEditor 컴포넌트가 받는 Props +interface PostEditorProps { + imageCategory: string; + forwardTitle?: (title: string) => void; + forwardContent?: (content: string) => void; + content?: string; + title?: string; + tag?: string; + setTag?: (tag: string) => void; +} +``` + +### 이벤트 핸들러 + +복사/붙여넣기나 드래그 앤 드롭 같은 브라우저 이벤트를 직접 처리할 수도 있어요. + +```typescript +// 붙여넣기 이벤트 처리 +const onPaste = useCallback((e: ClipboardEvent) => { + const items = e.clipboardData?.items; + // 여기서 이미지나 텍스트를 가공해서 붙여넣을 수 있어요. +}, [editor]); + +// 드래그 앤 드롭 이벤트 처리 +const onDrop = useCallback((event: React.DragEvent) => { + event.preventDefault(); + const files = event.dataTransfer.files; + // 끌어다 놓은 파일들을 처리하는 로직을 넣을 수 있어요. +}, []); +``` + +----- + +## ⚡️ 성능 최적화 + +### 1\. 리렌더링 방지 (Memoization) + +`React.memo`를 사용해서 props가 바뀌지 않으면 에디터가 불필요하게 다시 렌더링되지 않도록 막았어요. + +```typescript +export default memo(PostEditor); +``` + +### 2\. 코드 분할 (Code Splitting) + +무거운 플러그인은 `React.lazy`를 사용해서 필요할 때만 불러오도록 처리하여 초기 로딩 속도를 높였어요. + +```typescript +const LazyPlugin = lazy(() => import('./plugins/LazyPlugin')); +``` + +### 3\. 이미지 최적화 + +* 업로드 시 자동으로 이미지를 압축해요. +* WebP 포맷을 사용해서 이미지 용량을 줄였어요. +* 화면에 보일 때만 이미지를 로드하는 레이지 로딩을 적용했어요. + +----- + +## 🛠️ 트러블슈팅 + +### 이럴 땐 이렇게 해보세요 + +#### 1\. 이미지가 안 올라가요. + +* **원인**: S3 권한 문제, Presigned URL 만료, 파일 크기 제한 +* **해결책**: + * S3 버킷의 권한 설정(CORS 등)이 올바른지 확인해보세요. + * 서버에서 생성해주는 Presigned URL의 만료 시간이 너무 짧지 않은지 확인해보세요. + * 업로드하려는 이미지 파일 크기가 서버에서 정한 제한을 넘지 않는지 확인해보세요. + +#### 2\. 마크다운 단축키가 안 먹혀요. + +* **원인**: 플러그인 등록 누락 +* **해결책**: + * `MarkdownShortcutPlugin`이 에디터 플러그인 목록에 제대로 추가되어 있는지 확인해주세요. + * 원하는 단축키가 플러그인 설정에 포함되어 있는지 확인해보세요. + +#### 3\. 모바일에서 화면이 깨져요. + +* **원인**: 반응형 스타일링 미비 +* **해결책**: + * CSS 파일에 모바일 화면을 위한 미디어 쿼리(`@media (max-width: ...)`)가 잘 적용됐는지 확인해보세요. + * HTML의 `meta` 태그에 뷰포트 설정이 제대로 되어있는지 확인해주세요. + +----- + +## 💻 개발 환경 설정 + +### 필요한 라이브러리 설치 + +```bash +npm install @lexical/react @lexical/code @lexical/link @lexical/list +npm install @lexical/selection @lexical/utils +``` + +### 개발 서버 실행 + +```bash +npm run dev +``` + +### 빌드하기 + +```bash +npm run build +``` + +----- + +## 🙌 기여하고 싶으신가요? (Contribution Guide) + +PostEditor를 더 좋게 만드는 데 기여하고 싶으신가요? 환영합니다\! + +1. GitHub 이슈를 확인하거나 새로운 이슈를 만들어주세요. +2. 기능 개발을 위한 브랜치를 새로 만드세요 (`feature/기능이름`). +3. 코드를 작성하고 테스트를 추가해주세요. +4. Pull Request(PR)를 보내주시면 검토 후 반영하겠습니다. + +### 코드 스타일 + +* **TypeScript**를 사용해요. +* **ESLint**와 **Prettier**로 코드 스타일을 통일해요. +* 컴포넌트는 `React.FC` 타입을 사용해주세요. +* 스타일은 \*\*Emotion (CSS-in-JS)\*\*을 사용하고 있어요. + +----- + +이 문서는 PostEditor.tsx를 이해하고 사용하는 데 필요한 기본적인 내용을 담고 있어요. 더 궁금한 점이나 새로운 기능 아이디어가 있다면 언제든지 이슈로 알려주세요! \ No newline at end of file diff --git a/src/components/features/Post/PostEditor.tsx b/src/components/features/Post/PostEditor.tsx index 6004823f..a5a407eb 100644 --- a/src/components/features/Post/PostEditor.tsx +++ b/src/components/features/Post/PostEditor.tsx @@ -517,7 +517,6 @@ const PostWriteSection = forwardRef< const onPaste = useCallback( (e: ClipboardEvent) => { const items = e.clipboardData?.items; - if (items) { for (let i = 0; i < items.length; i++) { if (items[i].type.startsWith('image/')) { @@ -561,8 +560,10 @@ const PostWriteSection = forwardRef< break; } } + return; } }, + [editor] ); @@ -622,7 +623,7 @@ const PostSectionWrap: React.FC<{ const [editor] = useLexicalComposerContext(); const onDrop = useCallback((event: React.DragEvent) => { - event.preventDefault(); + event?.preventDefault(); const files = event.dataTransfer.files; if (files.length > 0) { const file = files[0]; diff --git a/src/components/features/Post/plugins/ClipboardPlugin.ts b/src/components/features/Post/plugins/ClipboardPlugin.ts index 985aa9e9..10feb2f2 100644 --- a/src/components/features/Post/plugins/ClipboardPlugin.ts +++ b/src/components/features/Post/plugins/ClipboardPlugin.ts @@ -1,17 +1,17 @@ +import { parseHtmlStrToLexicalNodes } from '@/components/features/Post/plugins/utils.ts'; + import { useEffect } from 'react'; import { $createCodeNode, DEFAULT_CODE_LANGUAGE } from '@lexical/code'; -import { $generateNodesFromDOM } from '@lexical/html'; +import { $generateHtmlFromNodes } from '@lexical/html'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; import { - $createRangeSelection, $createTextNode, - $getRoot, $getSelection, $insertNodes, $isElementNode, $isRangeSelection, - $setSelection, + $isTextNode, COMMAND_PRIORITY_CRITICAL, COPY_COMMAND, CUT_COMMAND, @@ -50,6 +50,7 @@ const CODE_KEYWORDS = [ 'False', 'static', 'while', + 'of', 'for', 'if', 'else', @@ -123,225 +124,35 @@ const detectLanguageByPrism = (text: string): string => { }; const copyNodesToClipboard = async ( + editor: LexicalEditor, nodes: Array, plainText: string ): Promise => { - if (!nodes.length) return Promise.resolve(false); + if (!nodes.length || !navigator.clipboard || !window.ClipboardItem) { + return false; + } try { const serializedNodes = nodes.map((node) => node.exportJSON()); const serializedContent = JSON.stringify(serializedNodes); - return navigator.clipboard - .writeText(plainText) - .then(() => { - try { - const clipboardData = new ClipboardItem({ - [LEXICAL_CLIPBOARD_TYPE]: new Blob([serializedContent], { - type: LEXICAL_CLIPBOARD_TYPE, - }), - 'text/plain': new Blob([plainText], { type: 'text/plain' }), - 'text/html': new Blob([serializedContent], { type: 'text/html' }), - }); - - return navigator.clipboard - .write([clipboardData]) - .then(() => { - return true; - }) - .catch(() => { - return true; - }); - } catch (error) { - console.warn('클립보드 API 오류:', error); - return true; - } - }) - .catch(() => { - return false; - }); - } catch (error) { - console.error('노드 직렬화 중 오류:', error); - return navigator.clipboard - .writeText(plainText) - .then(() => { - return true; - }) - .catch(() => { - return false; - }); - } -}; - -const copyCurrentLine = (editor: LexicalEditor): boolean => { - selectCurrentLine(editor); - - let selectedText = ''; - let nodesToCopy: Array = []; - - editor.getEditorState().read(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection) || selection.isCollapsed()) { - return; - } - - selectedText = selection.getTextContent(); + const htmlString = $generateHtmlFromNodes(editor, null); - nodesToCopy = selection.getNodes(); - }); - - if (nodesToCopy.length > 0) { - copyNodesToClipboard(nodesToCopy, selectedText).then((success) => { - if (!success) { - navigator.clipboard.writeText(selectedText); - } + const clipboardItem = new ClipboardItem({ + [LEXICAL_CLIPBOARD_TYPE]: new Blob([serializedContent], { + type: LEXICAL_CLIPBOARD_TYPE, + }), + 'text/plain': new Blob([plainText], { type: 'text/plain' }), + 'text/html': new Blob([htmlString], { type: 'text/html' }), + 'text/html-viewer': new Blob([htmlString], { type: 'text/html' }), }); + + await navigator.clipboard.write([clipboardItem]); return true; - } else if (selectedText) { - navigator.clipboard - .writeText(selectedText) - .then(() => {}) - .catch(() => {}); - return true; + } catch (error) { + console.warn('클립보드 복사 실패:', error); + return false; } - - return false; -}; - -const selectCurrentLine = (editor: LexicalEditor): boolean => { - editor.update(() => { - const selection = $getSelection(); - if (!$isRangeSelection(selection)) return; - - let currentNode = selection.anchor.getNode(); - if (!currentNode) return; - - const rootNode = $getRoot(); - - while (currentNode && currentNode !== rootNode) { - const parent = currentNode.getParent(); - if (!parent || parent === rootNode) break; - currentNode = parent; - } - - if (currentNode === rootNode || !$isElementNode(currentNode)) return; - - const children = currentNode.getChildren(); - if (children.length === 1 && children[0].getType() === 'link') { - // const link = children[0]; - // - // const newSelection = $createRangeSelection(); - // newSelection.anchor.set(link.getKey(), 0, 'element'); - // newSelection.focus.set( - // link.getKey(), - // link.getTextContentSize(), - // 'element' - // ); - // $setSelection(newSelection); - return true; - } - - if (currentNode.getType() === 'code') { - // const children = currentNode.getChildren(); - // const cursorOffset = selection.anchor.offset; - // let totalOffset = 0; - let foundLine = false; - - // for (const child of children) { - // const nodeText = child.getTextContent(); - // if (!nodeText) continue; - // - // if (totalOffset + nodeText.length < cursorOffset) { - // totalOffset += nodeText.length; - // continue; - // } - // - // const relativeOffset = cursorOffset - totalOffset; - // const textBeforeCursor = nodeText.substring(0, relativeOffset); - // const textAfterCursor = nodeText.substring(relativeOffset); - // - // let lineStartOffset = 0; - // const lastNewlineBeforeCursor = textBeforeCursor.lastIndexOf('\n'); - // if (lastNewlineBeforeCursor !== -1) { - // lineStartOffset = lastNewlineBeforeCursor + 1; - // } - // - // let lineEndOffset = nodeText.length; - // const firstNewlineAfterCursor = textAfterCursor.indexOf('\n'); - // if (firstNewlineAfterCursor !== -1) { - // lineEndOffset = relativeOffset + firstNewlineAfterCursor; - // } - // - // const newSelection = $createRangeSelection(); - // newSelection.anchor.set(child.getKey(), lineStartOffset, 'text'); - // newSelection.focus.set(child.getKey(), lineEndOffset, 'text'); - // $setSelection(newSelection); - // - foundLine = true; - // break; - // } - - if (foundLine) { - return true; - } - } - - let minLineStartOffset = Number.MAX_SAFE_INTEGER; - let maxLineEndOffset = 0; - let totalOffset = 0; - - const cursorOffset = selection.anchor.offset; - - for (const child of children) { - const nodeText = child.getTextContent(); - if (nodeText) { - const textBeforeCursor = nodeText.substring( - 0, - cursorOffset - totalOffset > 0 ? cursorOffset - totalOffset : 0 - ); - const lineStartOffset = textBeforeCursor.lastIndexOf('\n'); - const currentLineStart = - lineStartOffset === -1 ? 0 : lineStartOffset + 1; - - const lineEndOffset = nodeText.indexOf( - '\n', - Math.max(0, cursorOffset - totalOffset) - ); - const currentLineEnd = - lineEndOffset === -1 ? nodeText.length : lineEndOffset; - - minLineStartOffset = Math.min( - minLineStartOffset, - totalOffset + currentLineStart - ); - maxLineEndOffset = Math.max( - maxLineEndOffset, - totalOffset + currentLineEnd - ); - } - - totalOffset += nodeText.length; - } - - if (minLineStartOffset === Number.MAX_SAFE_INTEGER) { - minLineStartOffset = 0; - } - - const parentTextLength = currentNode.getTextContentSize(); - maxLineEndOffset = Math.min(maxLineEndOffset, parentTextLength); - - const newSelection = $createRangeSelection(); - newSelection.anchor.set( - currentNode.getKey(), - minLineStartOffset, - 'element' - ); - newSelection.focus.set(currentNode.getKey(), maxLineEndOffset, 'element'); - - $setSelection(newSelection); - }); - - return true; }; const registerCopyCommand = (editor: LexicalEditor) => { @@ -354,7 +165,7 @@ const registerCopyCommand = (editor: LexicalEditor) => { const selection = $getSelection(); if ($isRangeSelection(selection) && selection.isCollapsed()) { - shouldUseFallback = !copyCurrentLine(editor); + // shouldUseFallback = !copyCurrentLine(editor); } else if ($isRangeSelection(selection) && !selection.isCollapsed()) { let selectedText = ''; let nodesToCopy: Array = []; @@ -371,12 +182,8 @@ const registerCopyCommand = (editor: LexicalEditor) => { }); if (nodesToCopy.length > 0) { - copyNodesToClipboard(nodesToCopy, selectedText).then((success) => { - if (!success) { - navigator.clipboard.writeText(selectedText); - } - }); - shouldUseFallback = false; + copyNodesToClipboard(editor, nodesToCopy, selectedText); + shouldUseFallback = true; } } else if (!$isRangeSelection(selection)) { shouldUseFallback = true; @@ -390,22 +197,64 @@ const registerCopyCommand = (editor: LexicalEditor) => { }; const registerCutCommand = (editor: LexicalEditor) => { - return editor.registerCommand( + return editor.registerCommand( CUT_COMMAND, - (): boolean => { - let shouldUseFallback = true; + (event: ClipboardEvent): boolean => { + const shouldUseFallback = true; - editor.getEditorState().read(() => { + editor.update(() => { const selection = $getSelection(); if ($isRangeSelection(selection) && selection.isCollapsed()) { - selectCurrentLine(editor); - - shouldUseFallback = false; - } else if ($isRangeSelection(selection) && !selection.isCollapsed()) { - shouldUseFallback = true; - } else if (!$isRangeSelection(selection)) { - shouldUseFallback = true; + const anchor = selection.anchor; + const node = anchor.getNode(); + const topLevel = $isTextNode(node) + ? node.getTopLevelElementOrThrow() + : node; + + const first = topLevel.getFirstDescendant(); + const last = topLevel.getLastDescendant(); + + if ($isTextNode(first) && $isTextNode(last)) { + selection.setTextNodeRange( + first, + 0, + last, + last.getTextContentSize() + ); + + const sanitizeHtml = (html: string): string => { + const container = document.createElement('div'); + container.innerHTML = html; + + container.querySelectorAll('a').forEach((a) => { + a.removeAttribute('target'); + }); + + return container.innerHTML; + }; + const htmlString = sanitizeHtml( + $generateHtmlFromNodes(editor, selection) + ); + + event.clipboardData?.setData('text/html', htmlString); + event.clipboardData?.setData('text/html-viewer', htmlString); + + const target: LexicalNode | null = topLevel.getNextSibling(); + + if (target && $isElementNode(target)) { + const targetDescendant = target.getFirstDescendant(); + if (targetDescendant && $isTextNode(targetDescendant)) { + selection.setTextNodeRange( + targetDescendant, + 0, + targetDescendant, + 0 + ); + } + } + topLevel.remove(); + } } }); @@ -422,26 +271,26 @@ const registerPasteCommand = (editor: LexicalEditor) => { if (!event || !event.clipboardData) { return false; } + const html = event.clipboardData?.getData('text/html'); + if (html) { + return false; + } try { const lexicalData = event.clipboardData.getData(LEXICAL_CLIPBOARD_TYPE); if (lexicalData) { - console.log('??', lexicalData); return false; } const viewerData = event.clipboardData.getData('text/html-viewer'); if (viewerData) { - const parser = new DOMParser(); - const doc = parser.parseFromString(viewerData, 'text/html'); + const nodes = parseHtmlStrToLexicalNodes(viewerData); editor.update(() => { const selection = $getSelection(); if (!$isRangeSelection(selection)) return; - const nodes = $generateNodesFromDOM(editor, doc); - if (nodes.length > 0) { selection.insertNodes(nodes); } @@ -452,7 +301,6 @@ const registerPasteCommand = (editor: LexicalEditor) => { const plainText = event.clipboardData.getData('text/plain'); if (plainText) { if (looksLikeCode(plainText)) { - // event.preventDefault(); const language = detectLanguageByPrism(plainText); editor.update(() => { @@ -486,40 +334,6 @@ const registerPasteCommand = (editor: LexicalEditor) => { ); }; -const registerClipboardShortcuts = (editor: LexicalEditor) => { - const onKeyDown = (event: KeyboardEvent) => { - const isCtrlOrCmd = event.ctrlKey || event.metaKey; - - if (isCtrlOrCmd) { - // if (event.key === 'x') { - // editor.dispatchCommand(CUT_COMMAND, {} as ClipboardEvent); - // } else - if (event.key === 'c') { - editor.dispatchCommand(COPY_COMMAND, {} as ClipboardEvent); - } else if (event.key === 'v') { - editor.dispatchCommand(PASTE_COMMAND, {} as ClipboardEvent); - } - } - }; - - const removeKeyDownListener = editor.registerRootListener( - (rootElement: HTMLElement | null, prevRootElement: HTMLElement | null) => { - if (rootElement !== null) { - rootElement.addEventListener('keydown', onKeyDown, { capture: true }); - } - if (prevRootElement !== null) { - prevRootElement.removeEventListener('keydown', onKeyDown, { - capture: true, - }); - } - } - ); - - return () => { - removeKeyDownListener(); - }; -}; - export const ClipboardPlugin: React.FC = () => { const [editor] = useLexicalComposerContext(); @@ -527,13 +341,11 @@ export const ClipboardPlugin: React.FC = () => { const unregisterCopy = registerCopyCommand(editor); const unregisterCut = registerCutCommand(editor); const unregisterPaste = registerPasteCommand(editor); - const cleanup = registerClipboardShortcuts(editor); return () => { unregisterCopy(); unregisterCut(); unregisterPaste(); - cleanup(); }; }, [editor]); diff --git a/src/components/features/Post/plugins/DraggablePlugin.tsx b/src/components/features/Post/plugins/DraggablePlugin.tsx index db2881d8..90202151 100644 --- a/src/components/features/Post/plugins/DraggablePlugin.tsx +++ b/src/components/features/Post/plugins/DraggablePlugin.tsx @@ -546,7 +546,7 @@ const useDraggableBlockMenu = ( anchorElem ); - event.preventDefault(); + event?.preventDefault?.(); return true; }; diff --git a/src/components/features/Post/plugins/InitContentPlugin.tsx b/src/components/features/Post/plugins/InitContentPlugin.tsx index 88870472..14b9344b 100644 --- a/src/components/features/Post/plugins/InitContentPlugin.tsx +++ b/src/components/features/Post/plugins/InitContentPlugin.tsx @@ -1,385 +1,9 @@ -import { $createImageNode } from '@/components/features/Post/nodes/ImageNode'; +import { parseHtmlStrToLexicalNodes } from '@/components/features/Post/plugins/utils.ts'; import { useEffect, useState } from 'react'; -import { $createCodeNode } from '@lexical/code'; -import { $createLinkNode } from '@lexical/link'; -import { $createListItemNode, $createListNode } from '@lexical/list'; import { useLexicalComposerContext } from '@lexical/react/LexicalComposerContext'; -import { $createHeadingNode, HeadingTagType } from '@lexical/rich-text'; -import { - $createLineBreakNode, - $createParagraphNode, - $createTextNode, - $getRoot, - $getSelection, - $isTextNode, - LexicalNode, - TextNode, -} from 'lexical'; - -const createStyledTextNode = (text: string, style: string): TextNode => { - const textNode = $createTextNode(text); - textNode.setStyle(style); - - return textNode; -}; - -const mergeStyles = (parentStyle: string, childStyle: string): string => { - if (!parentStyle) { - return childStyle; - } - if (!childStyle) { - return parentStyle; - } - return parentStyle + '; ' + childStyle; -}; - -const formatMap: Record = { - 1: ['editor-text-bold'], - 2: ['editor-text-italic'], - 3: ['editor-text-bold', 'editor-text-italic'], - 4: ['editor-text-strikethrough'], - 5: ['editor-text-bold', 'editor-text-strikethrough'], - 6: ['editor-text-italic', 'editor-text-strikethrough'], - 7: ['editor-text-bold', 'editor-text-italic', 'editor-text-strikethrough'], -}; - -const getFormatFromClasses = (classes: string[]): number => { - for (const [format, formatClasses] of Object.entries(formatMap)) { - if ( - formatClasses.every((cls) => classes.includes(cls)) && - formatClasses.length === classes.length - ) { - return parseInt(format, 10); - } - } - - return 0; -}; - -// 나중에 따로 뺌 -// eslint-disable-next-line -export const parseHtmlStrToLexicalNodes = ( - htmlString: string -): LexicalNode[] => { - const parser = new DOMParser(); - const dom = parser.parseFromString(htmlString, 'text/html'); - const { body } = dom; - - const lexicalNodes: LexicalNode[] = []; - - const traverse = (node: ChildNode): LexicalNode | LexicalNode[] | null => { - // 텍스트 노드 - if (node.nodeType === Node.TEXT_NODE) { - const textContent = node.textContent ?? ''; - if (!textContent) return null; - - const parentEl = node.parentElement; - if (parentEl?.tagName === 'SPAN') { - const style = parentEl.getAttribute('style') ?? ''; - const styledTextNode = createStyledTextNode(textContent, style); - styledTextNode.setFormat( - getFormatFromClasses(parentEl.className.split(' ') ?? '') - ); - return styledTextNode; - } else { - return $createTextNode(textContent); - } - } - - if (node.nodeType === Node.ELEMENT_NODE) { - const element = node as HTMLElement; - const tagName = element.tagName.toLowerCase(); - - switch (tagName) { - // -> LinkNode - case 'a': { - const href = element.getAttribute('href') ?? ''; - const target = element.getAttribute('target'); - const linkNode = $createLinkNode(href); - - if (target === '_blank') { - linkNode.setTarget('_blank'); - } - - element.childNodes.forEach((child) => { - const childLexicalNode = traverse(child); - if (childLexicalNode) { - if (Array.isArray(childLexicalNode)) { - linkNode.append(...childLexicalNode); - } else { - linkNode.append(childLexicalNode); - } - } - }); - - if (target === '_blank') { - linkNode.setRel('noopener noreferrer'); - } - - return linkNode; - } - - //

-> ParagraphNode - case 'p': { - const paragraph = $createParagraphNode(); - element.childNodes.forEach((child) => { - const childLexicalNode = traverse(child); - if (childLexicalNode) { - if (Array.isArray(childLexicalNode)) { - paragraph.append(...childLexicalNode); - } else { - paragraph.append(childLexicalNode); - } - } - }); - return paragraph; - } - - case 'pre': { - const language = element.getAttribute('data-language') ?? ''; - const codeNode = $createCodeNode(language); - - element.childNodes.forEach((child) => { - if ( - child.nodeType === Node.ELEMENT_NODE && - (child as HTMLElement).tagName.toLowerCase() === 'br' - ) { - const lineBreakNode = $createLineBreakNode(); - codeNode.append(lineBreakNode); - } else { - const childLexicalNode = traverse(child); - if (childLexicalNode) { - if (Array.isArray(childLexicalNode)) { - childLexicalNode.forEach((childNode) => { - codeNode.append(childNode); - }); - } else { - codeNode.append(childLexicalNode); - } - } - } - }); - - return codeNode; - } - - //

,

,

,

,

,
-> HeadingNode - case 'h1': - case 'h2': - case 'h3': - case 'h4': - case 'h5': - case 'h6': { - const headingLevel = ('h' + - parseInt(tagName[1], 10)) as HeadingTagType; - const headingNode = $createHeadingNode(headingLevel); - - element.childNodes.forEach((child) => { - const childLexicalNode = traverse(child); - if (childLexicalNode) { - if (Array.isArray(childLexicalNode)) { - headingNode.append(...childLexicalNode); - } else { - headingNode.append(childLexicalNode); - } - } - }); - - return headingNode; - } - - //