From 6b6012fc31c5da7167bd4934bd01bba3285ad8e2 Mon Sep 17 00:00:00 2001 From: choi1five Date: Sun, 7 Sep 2025 23:07:55 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat(util):=20=EA=B9=8A=EC=9D=80=20?= =?UTF-8?q?=EB=B9=84=EA=B5=90=EB=A5=BC=20=EC=9C=84=ED=95=9C=20deepEqual=20?= =?UTF-8?q?=ED=95=A8=EC=88=98=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/utils/deep-equal.ts | 50 +++++++++++++++++++++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 src/lib/utils/deep-equal.ts diff --git a/src/lib/utils/deep-equal.ts b/src/lib/utils/deep-equal.ts new file mode 100644 index 0000000..f56dacc --- /dev/null +++ b/src/lib/utils/deep-equal.ts @@ -0,0 +1,50 @@ +/** + * 두 값 간의 깊은 동등성 비교를 수행합니다. + * @param a - 비교할 첫 번째 값 + * @param b - 비교할 두 번째 값 + * @returns 값들이 깊은 동등성을 만족하면 true, 그렇지 않으면 false + */ +export function deepEqual(a: unknown, b: unknown): boolean { + if (a === b) return true; + + // NaN 처리 + if (Number.isNaN(a) && Number.isNaN(b)) return true; + + // 타입이 다르거나 null/undefined인 경우 + if (typeof a !== typeof b || a == null || b == null) return false; + + // 배열 + if (Array.isArray(a) && Array.isArray(b)) { + if (a.length !== b.length) return false; + for (let i = 0; i < a.length; i++) { + if (!deepEqual(a[i], b[i])) return false; + } + return true; + } + + // 객체 + if (typeof a === 'object' && typeof b === 'object') { + // Date 특별 처리 + if (a instanceof Date && b instanceof Date) { + return a.getTime() === b.getTime(); + } + + const keysA = Object.keys(a); + const keysB = Object.keys(b); + + if (keysA.length !== keysB.length) return false; + + for (const key of keysA) { + if (!deepEqual((a as Record)[key], (b as Record)[key])) + return false; + } + return true; + } + + return false; +} + +/** + * deepEqual 함수의 타입 정의 + */ +export type DeepEqualFn = typeof deepEqual; From aafdc7d73fb89483d708336cc1a04cdf989386dc Mon Sep 17 00:00:00 2001 From: choi1five Date: Sun, 7 Sep 2025 23:08:49 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat(util):=20debounce=20=ED=95=A8=EC=88=98?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/utils/debounce.ts | 52 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 52 insertions(+) create mode 100644 src/lib/utils/debounce.ts diff --git a/src/lib/utils/debounce.ts b/src/lib/utils/debounce.ts new file mode 100644 index 0000000..818a95a --- /dev/null +++ b/src/lib/utils/debounce.ts @@ -0,0 +1,52 @@ +/** + * 디바운스된 함수 인터페이스 + */ +export interface DebouncedFunction { + /** 디바운스된 함수 호출 */ + (...args: TArgs): void; + /** 대기 중인 실행을 취소하고 상태를 초기화 */ + cancel(): void; +} + +/** + * Debounce 함수 - 연속된 호출에서 마지막 호출만 지연 후 실행 + * @param func 디바운스할 함수 + * @param delay 지연 시간 (밀리초) + * @returns cancel 메서드를 포함한 디바운스된 함수 + */ +export function debounce( + func: (...args: TArgs) => void, + delay: number, +): DebouncedFunction { + let timerId: NodeJS.Timeout | null = null; + let lastArgs: TArgs | null = null; + + const debouncedFn = (...args: TArgs) => { + lastArgs = args; + + // 기존 타이머가 있으면 취소 + if (timerId) { + clearTimeout(timerId); + } + + // 새로운 타이머 설정 + timerId = setTimeout(() => { + if (lastArgs !== null) { + func(...lastArgs); + lastArgs = null; + } + timerId = null; + }, delay); + }; + + // 타이머 정리를 위한 cleanup 함수 추가 + debouncedFn.cancel = () => { + if (timerId) { + clearTimeout(timerId); + timerId = null; + } + lastArgs = null; + }; + + return debouncedFn; +} From 5dc521c9f60be093a09cbcb71dde034402e8f1ba Mon Sep 17 00:00:00 2001 From: choi1five Date: Sun, 7 Sep 2025 23:10:13 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat(draft):=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EA=B0=90=EC=A7=80=20=EB=B0=8F?= =?UTF-8?q?=20=EB=B3=80=ED=99=98=20=EC=96=B4=EB=8C=91=ED=84=B0=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/editor/editor-event-adapter.ts | 790 +++++++++++++++++++++++++ 1 file changed, 790 insertions(+) create mode 100644 src/lib/editor/editor-event-adapter.ts diff --git a/src/lib/editor/editor-event-adapter.ts b/src/lib/editor/editor-event-adapter.ts new file mode 100644 index 0000000..8278ab4 --- /dev/null +++ b/src/lib/editor/editor-event-adapter.ts @@ -0,0 +1,790 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ + +import { TElement, Value } from 'platejs'; +import { TPlateEditor } from 'platejs/react'; + +import { debounce, type DebouncedFunction } from '../utils/debounce'; +import { deepEqual } from '../utils/deep-equal'; +import { throttle, type ThrottledFunction } from '../utils/throttle'; + +interface PlateOperation { + type: string; + path?: number[]; + offset?: number; + text?: string; + node?: any; + position?: number; + properties?: any; + newProperties?: any; + [key: string]: any; +} + +// 내부 처리용 중간 텍스트 이벤트 +interface InternalTextEvent { + type: 'INSERT_TEXT' | 'REMOVE_TEXT'; + draftId: string; + blockId?: string; +} + +// 실제 API 전송용 컨텐츠 변경 요청 (TODO: 추후 수정 필요) +export interface ContentChangeRequest { + type: 'NEW_BLOCK' | 'REMOVED' | 'SPLITTED' | 'MERGING' | 'REORDER' | 'SHORT_REST' | 'LONG_TYPING'; + draftId: string; + blockId?: string; + data: { + // type별 필요 데이터 + position?: number; // 블록 위치 + targetBlockId?: string; // merge/split용 대상 블록 + targets?: any[]; + // MERGING용 확장 데이터 + sourceBlockId?: string; // 병합될 하위 블록 ID + + // 미디어 관련 + mediaType?: 'image' | 'video' | 'file'; + mediaUrl?: string; + needsUpload?: boolean; + }; +} + +export class PlateEditorAdapter { + private changeHandler: ((request: ContentChangeRequest) => void) | null = null; + + // operations 처리 전 상태 캡처를 위한 스냅샷 + private blockSnapshots = new Map(); + private lastEditorState: Value | null = null; + + // 텍스트 입력 처리 최적화 시스템 (Focus Block만 사용) + private focusedBlockId: string | null = null; // 현재 포커스된 블록 + private focusedBlockInitialSnapshot: TElement | null = null; // 포커스 블록의 초기 상태 스냅샷 + private lastProcessedBlockSnapshot: TElement | null = null; // 마지막 처리된 블록 상태 스냅샷 + + // 전역 함수들 + private debouncedTextProcessor!: DebouncedFunction<[]>; + private throttledLongTypingProcessor!: ThrottledFunction<[]>; + + // 최적화 설정 상수 + private readonly DEBOUNCE_DELAY = 300; // 디바운스 지연시간 (ms) + private readonly LONG_TYPING_THRESHOLD = 5000; // LONG_TYPING 스로틀 간격 (ms) + + // Plate.js Operation 타입 상수 + private readonly OPERATION_TYPES = { + INSERT_TEXT: 'insert_text', + REMOVE_TEXT: 'remove_text', + INSERT_NODE: 'insert_node', + REMOVE_NODE: 'remove_node', + MOVE_NODE: 'move_node', + SPLIT_NODE: 'split_node', + MERGE_NODE: 'merge_node', + SET_NODE: 'set_node', + } as const; + + // 내부 텍스트 이벤트 타입 상수 + private readonly INTERNAL_TEXT_EVENTS = { + INSERT_TEXT: 'INSERT_TEXT', + REMOVE_TEXT: 'REMOVE_TEXT', + } as const; + + // API 전송용 이벤트 타입 상수 + private readonly CONTENT_CHANGE_EVENTS = { + NEW_BLOCK: 'NEW_BLOCK', + REMOVED: 'REMOVED', + SPLITTED: 'SPLITTED', + MERGING: 'MERGING', + REORDER: 'REORDER', + SHORT_REST: 'SHORT_REST', + LONG_TYPING: 'LONG_TYPING', + } as const; + + constructor( + private plateEditor: TPlateEditor, + private draftId: string, + ) { + // 초기화: 디바운스/스로틀 래퍼 생성 + this.debouncedTextProcessor = debounce(() => { + this.processTextInput(); + }, this.DEBOUNCE_DELAY); + + this.throttledLongTypingProcessor = throttle(() => { + this.processLongTyping(); + }, this.LONG_TYPING_THRESHOLD); + } + + // EditorAdapter Interface Implementation + + /** + * 변경사항 감지 리스너 등록 + */ + onContentChange(handler: (request: ContentChangeRequest) => void): void { + this.changeHandler = handler; + } + + /** + * 에디터 콘텐츠 가져오기 + */ + getContent(): Value { + return this.plateEditor.children; + } + + /** + * 에디터 콘텐츠 설정 + */ + setContent(content: Value): void { + this.plateEditor.children = content; + } + + // Operation Processing + + /** + * merge_node operations을 위한 이전 상태에서 스냅샷 생성 + */ + private capturePreOperationState(operations: PlateOperation[]): void { + this.blockSnapshots.clear(); + + if (!this.lastEditorState) { + return; + } + + // merge_node operations이 있으면 이전 상태에서 관련 블록들 캡처 + operations.forEach((op) => { + if (op.type === 'merge_node' && op.path && op.path.length === 1) { + const deletedBlockIndex = op.path[0]; // 사라질 블록 (하위) + const mergedBlockIndex = deletedBlockIndex - 1; // 병합받을 블록 (상위) + + // 이전 상태에서 삭제될 블록 정보 저장 + if ( + this.lastEditorState && + deletedBlockIndex >= 0 && + deletedBlockIndex < this.lastEditorState.length + ) { + const deletedBlock = this.lastEditorState[deletedBlockIndex]; + this.blockSnapshots.set(deletedBlockIndex, deletedBlock); + } + + // 이전 상태에서 상위 블록 정보 저장 (비교용) + if ( + this.lastEditorState && + mergedBlockIndex >= 0 && + mergedBlockIndex < this.lastEditorState.length + ) { + const upperBlock = this.lastEditorState[mergedBlockIndex]; + + this.blockSnapshots.set(mergedBlockIndex, upperBlock); + } + } + }); + } + + /** + * 현재 에디터 상태 저장 (processOperations 마지막에 호출) + */ + private saveCurrentEditorState(): void { + if (this.plateEditor?.children) { + this.lastEditorState = this.plateEditor.children; + } + } + + /** + * Operations를 처리하는 공개 메서드 + * Plate.js onChange에서 직접 호출됩니다. + */ + processOperations(operations: PlateOperation[]): void { + // 입력 검증 + if (!operations || operations.length === 0) { + return; + } + + // 블록 레벨 operations가 있으면 진행 중인 타이머만 클린업 + if (this.hasBlockLevelOperations(operations)) { + this.clearTextProcessingTimers(); + } + + // operations 처리 전 상태 캡처 (이전 상태 사용) + this.capturePreOperationState(operations); + + if (!this.changeHandler) { + return; + } + + try { + // 유효한 operations만 필터링 + const validOperations = this.filterValidOperations(operations); + + if (validOperations.length === 0) { + return; + } + + // Operations를 변환 + const changeRequests = this.convertOperationsToRequestsCombined(validOperations); + + if (changeRequests.length === 0) { + return; + } + + // 각 요청을 타입별로 최적화하여 처리 + changeRequests.forEach((request) => { + try { + this.processRequest(request); + } catch (handlerError) { + console.error('[PlateEditorAdapter] Error in change handler:', handlerError); + } + }); + } catch (error) { + console.error('[PlateEditorAdapter] Error processing operations:', error); + } + + // 현재 상태를 다음 번을 위해 저장 + this.saveCurrentEditorState(); + } + + /** + * 유효한 operations만 필터링 + */ + private filterValidOperations(operations: PlateOperation[]): PlateOperation[] { + return operations.filter((op) => { + // path가 없는 operations 제외 (set_selection 등) + if (!op.path || !Array.isArray(op.path) || op.path.length === 0) { + return false; + } + + // 알려진 operation types만 허용 + return Object.values(this.OPERATION_TYPES).includes( + op.type as (typeof this.OPERATION_TYPES)[keyof typeof this.OPERATION_TYPES], + ); + }); + } + + /** + * 단일 Operation을 ContentChangeRequest 또는 InternalTextEvent로 변환 + */ + private convertSingleOperation( + op: PlateOperation, + ): ContentChangeRequest | InternalTextEvent | null { + if (!this.draftId || !op.path) { + return null; + } + + const blockId = this.extractBlockId(op.path); + + switch (op.type) { + case this.OPERATION_TYPES.INSERT_TEXT: + case this.OPERATION_TYPES.REMOVE_TEXT: + return this.createTextChangeEvent(op, blockId); + + case this.OPERATION_TYPES.INSERT_NODE: + return this.createNodeInsertRequest(op, blockId); + + case this.OPERATION_TYPES.REMOVE_NODE: + return this.createNodeRemoveRequest(op, blockId); + + case this.OPERATION_TYPES.MOVE_NODE: + return this.createNodeMoveRequest(op, blockId); + + case this.OPERATION_TYPES.SPLIT_NODE: + return this.createSplitRequest(op, blockId); + + case this.OPERATION_TYPES.MERGE_NODE: + return this.createMergeRequest(op); + + case this.OPERATION_TYPES.SET_NODE: + return this.createNodeUpdateRequest(op, blockId); + + default: + console.warn(`[PlateEditorAdapter] Unknown operation type: ${op.type}`); + return null; + } + } + + /** + * 텍스트 변경 이벤트 생성 (내부 처리용) + */ + private createTextChangeEvent(op: PlateOperation, blockId: string): InternalTextEvent { + return { + type: + op.type === this.OPERATION_TYPES.INSERT_TEXT + ? this.INTERNAL_TEXT_EVENTS.INSERT_TEXT + : this.INTERNAL_TEXT_EVENTS.REMOVE_TEXT, + draftId: this.draftId, + blockId, + }; + } + + /** + * 노드 삽입 요청 생성 + */ + private createNodeInsertRequest( + op: PlateOperation, + blockId: string, + ): ContentChangeRequest | null { + const isBlockLevel = op.path!.length === 1; + + if (isBlockLevel) { + return { + type: this.CONTENT_CHANGE_EVENTS.NEW_BLOCK, + draftId: this.draftId, + blockId, + data: { + targets: [op.node], // 새로 추가된 블록 + position: op.path![0], + }, + }; + } + + return null; + } + + /** + * 노드 제거 요청 생성 + */ + private createNodeRemoveRequest( + op: PlateOperation, + blockId: string, + ): ContentChangeRequest | null { + const isBlockLevel = op.path!.length === 1; + + if (isBlockLevel) { + return { + type: this.CONTENT_CHANGE_EVENTS.REMOVED, + draftId: this.draftId, + blockId, + data: { + position: op.path![0], + }, + }; + } + + return null; + } + + /** + * 노드 이동 요청 생성 + */ + private createNodeMoveRequest(op: PlateOperation, blockId: string): ContentChangeRequest { + // 블록 내부 이동인지 블록 간 이동인지 판단 + const isInternalMove = this.isInternalMove(op); + + if (isInternalMove) { + return { + type: this.CONTENT_CHANGE_EVENTS.SHORT_REST, + draftId: this.draftId, + blockId, + data: { + targets: [this.getBlockContent(op.path![0])], // 변경된 블록 + }, + }; + } + + return { + type: this.CONTENT_CHANGE_EVENTS.REORDER, + draftId: this.draftId, + blockId, + data: { + position: op.path![0], + ...(op.newPath && { targetPosition: op.newPath[0] }), + }, + }; + } + + /** + * 블록 분할 요청 생성 + */ + private createSplitRequest(op: PlateOperation, blockId: string): ContentChangeRequest | null { + const isBlockLevelSplit = op.path!.length === 1; + + if (isBlockLevelSplit) { + // 실제 블록 분할 (Enter로 새 블록 생성) + const currentBlockIndex = op.path![0]; + const currentBlock = this.getBlockContent(currentBlockIndex); + const splitPosition = op.position || 0; + + // 분할 후 생성될 새 블록의 내용을 예상하여 targets 배열 구성 + const originalBlock = currentBlock; + const newBlockIndex = currentBlockIndex + 1; + const newBlock = this.getBlockContent(newBlockIndex); + + const hasNewBlockContent = newBlock && newBlock.children[0].text !== ''; + + return { + type: hasNewBlockContent + ? this.CONTENT_CHANGE_EVENTS.SPLITTED + : this.CONTENT_CHANGE_EVENTS.NEW_BLOCK, + draftId: this.draftId, + blockId, + data: { + targets: [originalBlock, newBlock], + position: hasNewBlockContent ? splitPosition : newBlockIndex, + }, + }; + } + + return null; + } + + /** + * 블록 병합 요청 생성 + */ + private createMergeRequest(op: PlateOperation): ContentChangeRequest | null { + const isBlockLevelMerge = op.path!.length === 1; + + if (isBlockLevelMerge) { + const deletedBlockIndex = op.path![0]; // 사라질 블록 (하위) + const mergedBlockIndex = deletedBlockIndex - 1; // 병합받을 블록 (상위) + const deletedBlockSnapshot = this.blockSnapshots.get(deletedBlockIndex); // 스냅샷에서 삭제된 블록의 이전 내용 가져오기 + const currentMergedBlock = this.getBlockContent(mergedBlockIndex); // 현재 상위 블록(병합된 블록)의 내용 가져오기 + const previousMergedBlock = this.blockSnapshots.get(mergedBlockIndex); // 스냅샷에서 상위 블록의 이전 내용 가져오기 (비교용) + + // 상위 블록의 내용이 변경되었는지 확인 + const upperBlockChanged = !this.isBlockContentEqual( + currentMergedBlock ?? null, + previousMergedBlock ?? null, + ); + + if (upperBlockChanged) { + // 상위 블록 내용이 변경됨 → 실제 병합 → MERGING + return { + type: this.CONTENT_CHANGE_EVENTS.MERGING, + draftId: this.draftId, + blockId: this.extractBlockId([mergedBlockIndex]), + data: { + targets: [currentMergedBlock, deletedBlockSnapshot], // [상위블록, 하위블록] + position: mergedBlockIndex, + }, + }; + } + + // 상위 블록 내용 변경 없음 → 빈 블록 제거 → REMOVED + return { + type: this.CONTENT_CHANGE_EVENTS.REMOVED, + draftId: this.draftId, + blockId: this.extractBlockId([deletedBlockIndex]), + data: { + targets: [deletedBlockSnapshot], // 삭제된 블록 정보 + position: deletedBlockIndex, + }, + }; + } + + return null; + } + + /** + * 노드 업데이트 요청 생성 + */ + private createNodeUpdateRequest(op: PlateOperation, blockId: string): ContentChangeRequest { + return { + type: this.CONTENT_CHANGE_EVENTS.SHORT_REST, + draftId: this.draftId, + blockId, + data: { + targets: [this.getBlockContent(op.path![0])], // 변경된 블록 + }, + }; + } + + /** + * 요청 처리 (내부 이벤트와 API 요청 구분 처리) + */ + private processRequest(request: ContentChangeRequest | InternalTextEvent): void { + if (!this.changeHandler) return; + + // 내부 텍스트 이벤트 처리 + if (this.isInternalTextEvent(request)) { + // 모든 내부 텍스트 이벤트에 대해 디바운스 처리 (실제 처리 여부는 processTextInput에서 판단) + this.handleTextInput(request); + } else { + // API 전송용 요청 처리 - 마지막 로깅된 상태와 중복 여부 확인 + const blockIndex = request.blockId ? this.getBlockIndexFromId(request.blockId) : -1; + const currentBlockContent = blockIndex >= 0 ? this.getBlockContent(blockIndex) : null; + + // SHORT_REST와 LONG_TYPING 중복 처리 방지 + if ( + (request.type === this.CONTENT_CHANGE_EVENTS.SHORT_REST || + request.type === this.CONTENT_CHANGE_EVENTS.LONG_TYPING) && + this.isBlockContentEqual(currentBlockContent, this.lastProcessedBlockSnapshot) + ) { + // 마지막 처리된 상태와 동일하면 중복 처리하지 않음 + return; + } + + // 실제 API 전송 및 로깅(외부에서 주입한 로직 실행) + this.changeHandler(request); + + // 처리된 상태 저장 (SHORT_REST, LONG_TYPING만) + if ( + request.type === this.CONTENT_CHANGE_EVENTS.SHORT_REST || + request.type === this.CONTENT_CHANGE_EVENTS.LONG_TYPING + ) { + this.lastProcessedBlockSnapshot = currentBlockContent; + } + } + } + + /** + * 내부 텍스트 이벤트 여부 확인 + */ + private isInternalTextEvent( + request: ContentChangeRequest | InternalTextEvent, + ): request is InternalTextEvent { + return ( + request.type === this.INTERNAL_TEXT_EVENTS.INSERT_TEXT || + request.type === this.INTERNAL_TEXT_EVENTS.REMOVE_TEXT + ); + } + + /** + * 텍스트 입력 처리 + */ + private handleTextInput(request: InternalTextEvent): void { + const blockId = request.blockId; + + // 포커스 블록 변경 시 기존 처리 플러시 및 새 스냅샷 생성 + if (blockId && this.focusedBlockId !== blockId) { + this.flushTextInput(); + this.focusedBlockId = blockId; + // 새 블록의 초기 상태 스냅샷 생성 + const blockIndex = this.getBlockIndexFromId(blockId); + const blockContent = this.getBlockContent(blockIndex); + this.focusedBlockInitialSnapshot = blockContent ?? null; + } + + this.debouncedTextProcessor(); + this.throttledLongTypingProcessor(); + } + + /** + * SHORT_REST 처리 + */ + private processTextInput(): void { + if (!this.focusedBlockId) { + return; + } + + // 현재 포커스된 블록의 실제 내용 가져오기 + const blockIndex = this.getBlockIndexFromId(this.focusedBlockId); + const currentBlockContent = this.getBlockContent(blockIndex); + + // 초기 스냅샷과 비교하여 실질적 변경 여부 확인 + if (this.isBlockContentEqual(currentBlockContent, this.focusedBlockInitialSnapshot)) { + // 블록 내용이 초기 상태와 동일하면 처리하지 않음 + return; + } + + // 마지막 처리된 상태와 중복 여부 확인 + if (this.isBlockContentEqual(currentBlockContent, this.lastProcessedBlockSnapshot)) { + // 마지막 처리된 상태와 동일하면 중복 처리하지 않음 + return; + } + + // SHORT_REST 처리용 이벤트 생성 및 전송 + const processRequest: ContentChangeRequest = { + type: this.CONTENT_CHANGE_EVENTS.SHORT_REST, + draftId: this.draftId, + blockId: this.focusedBlockId, + data: { + targets: [currentBlockContent], // 변경된 블록 정보 + }, + }; + + this.processRequest(processRequest); + } + + /** + * LONG_TYPING 처리 + */ + private processLongTyping(): void { + if (!this.focusedBlockId) { + return; + } + + // 현재 포커스된 블록의 실제 내용 가져오기 + const blockIndex = this.getBlockIndexFromId(this.focusedBlockId); + const currentBlockContent = this.getBlockContent(blockIndex); + + // 초기 스냅샷과 비교하여 실질적 변경 여부 확인 + const isEqualToInitial = this.isBlockContentEqual( + currentBlockContent, + this.focusedBlockInitialSnapshot, + ); + if (isEqualToInitial) { + return; + } + + // 마지막 처리된 상태와 중복 여부 확인 + const isEqualToLastProcessed = this.isBlockContentEqual( + currentBlockContent, + this.lastProcessedBlockSnapshot, + ); + if (isEqualToLastProcessed) { + return; + } + + // LONG_TYPING 처리용 이벤트 생성 + const processRequest: ContentChangeRequest = { + type: this.CONTENT_CHANGE_EVENTS.LONG_TYPING, + draftId: this.draftId, + blockId: this.focusedBlockId, + data: { + targets: [currentBlockContent], // 변경된 블록 정보 + }, + }; + + this.processRequest(processRequest); + } + + /** + * 텍스트 처리 즉시 플러시 (포커스 변경 시) + */ + private flushTextInput(): void { + this.clearTextProcessingTimers(); + this.resetTextProcessingState(); + return; + } + + /** + * 텍스트 처리 상태만 리셋 (이벤트 전송 없음) + */ + private resetTextProcessingState(): void { + this.focusedBlockId = null; + this.focusedBlockInitialSnapshot = null; + this.lastProcessedBlockSnapshot = null; + } + + /** + * 텍스트 처리 타이머만 클린업 (상태는 유지) + */ + private clearTextProcessingTimers(): void { + // 디바운스된 함수 취소 + if (this.debouncedTextProcessor) { + this.debouncedTextProcessor.cancel(); + } + + // 스로틀된 함수 취소 + if (this.throttledLongTypingProcessor) { + this.throttledLongTypingProcessor.cancel(); + } + } + + /** + * 효율적인 단일 순회로 operations 분류 처리 + */ + private convertOperationsToRequestsCombined( + operations: PlateOperation[], + ): (ContentChangeRequest | InternalTextEvent)[] { + const requests: (ContentChangeRequest | InternalTextEvent)[] = []; + + for (const op of operations) { + const result = this.convertSingleOperation(op); + + if (result) { + requests.push(result); + } + } + + return requests; + } + + // Helper Methods + + /** + * 블록 레벨 operations가 있는지 확인 (NEW_BLOCK, SPLITTED 등 감지) + */ + private hasBlockLevelOperations(operations: PlateOperation[]): boolean { + return operations.some((op) => { + // 블록 레벨 operations 감지 (path 길이가 1이고 특정 타입인 경우) + if (!op.path || op.path.length !== 1) { + return false; + } + + return ( + op.type === this.OPERATION_TYPES.INSERT_NODE || // NEW_BLOCK + op.type === this.OPERATION_TYPES.REMOVE_NODE || // REMOVED + op.type === this.OPERATION_TYPES.SPLIT_NODE || // SPLITTED + op.type === this.OPERATION_TYPES.MERGE_NODE || // MERGING + op.type === this.OPERATION_TYPES.MOVE_NODE // REORDER + ); + }); + } + + /** + * 두 블록의 내용이 동일한지 비교 + */ + private isBlockContentEqual(newBlock: TElement | null, prevBlock: TElement | null): boolean { + if (newBlock === null && prevBlock === null) return true; + if (newBlock === null || prevBlock === null) return false; + + return deepEqual(newBlock, prevBlock); + } + + /** + * path에서 블록 ID 추출 + */ + private extractBlockId(path: number[]): string { + const blockIndex = path[0]; + + const block = this.plateEditor.children[blockIndex]; + return block?.id as string; + } + + /** + * 블록 내용 가져오기 + */ + private getBlockContent(blockIndex: number): TElement | null { + if ( + !this.plateEditor?.children || + blockIndex < 0 || + blockIndex >= this.plateEditor.children.length + ) { + return null; + } + + return this.plateEditor.children[blockIndex]; + } + + /** + * 내부 이동인지 판단 (children 내에서의 이동) + */ + private isInternalMove(op: PlateOperation): boolean { + return op.path!.length > 1; + } + + /** + * 블록 ID에서 인덱스 추출 + */ + private getBlockIndexFromId(blockId: string): number { + if (!this.plateEditor?.children) { + return -1; + } + + return this.plateEditor.children.findIndex((child) => (child as TElement).id === blockId); + } + + /** + * 특정 블록의 ID를 수정 + * @param blockIndex 블록의 인덱스 (0부터 시작) + * @param newId 새로운 ID 값 + * @returns 성공 여부 + */ + updateBlockId(blockIndex: number, newId: string): boolean { + if (!this.plateEditor?.children) { + console.warn('[PlateEditorAdapter] No editor children available'); + return false; + } + + if (blockIndex < 0 || blockIndex >= this.plateEditor.children.length) { + console.warn('[PlateEditorAdapter] Invalid block index:', blockIndex); + return false; + } + + // 블록 가져오기 + const block = this.plateEditor.children[blockIndex] as TElement; + + if (!block) { + return false; + } + + // 히스토리에 남지 않도록 직접 블록 속성 수정 (백엔드 동기화용) 시스템 내부 동기화 작업이므로 Undo/Redo 대상이 아님 + const blockToUpdate = this.plateEditor.children[blockIndex] as TElement; + blockToUpdate.id = newId; + + return true; + } +} From 95c396a4de3a5c5b4d7aaf4b25c3dad0f674e0e9 Mon Sep 17 00:00:00 2001 From: choi1five Date: Sun, 7 Sep 2025 23:24:03 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat(layout):=20=EB=A3=A8=ED=8A=B8=20?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=95=84=EC=9B=83=20Toaster=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/app/layout.tsx | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/app/layout.tsx b/src/app/layout.tsx index c768db2..38f89a7 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,5 +1,6 @@ import type { Metadata } from 'next'; import './globals.css'; +import { Toaster } from 'sonner'; export const metadata: Metadata = { title: 'Blolet', @@ -13,7 +14,10 @@ export default function RootLayout({ }>) { return ( - {children} + + {children} + + ); } From 4848c12cc8caa205e39678512d805fa5a6fabdda Mon Sep 17 00:00:00 2001 From: choi1five Date: Sun, 7 Sep 2025 23:25:31 +0900 Subject: [PATCH 5/5] =?UTF-8?q?feat(draft):=20=EC=97=90=EB=94=94=ED=84=B0?= =?UTF-8?q?=20onChange=20=EC=9D=B4=EB=B2=A4=ED=8A=B8=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/editor/components/plate-editor.tsx | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/src/lib/editor/components/plate-editor.tsx b/src/lib/editor/components/plate-editor.tsx index 1a45c8e..9c9e860 100644 --- a/src/lib/editor/components/plate-editor.tsx +++ b/src/lib/editor/components/plate-editor.tsx @@ -1,17 +1,37 @@ 'use client'; + import { Plate, usePlateEditor } from 'platejs/react'; +import { toast } from 'sonner'; import * as React from 'react'; import { EditorKit } from './editor-kit'; +import { PlateEditorAdapter } from '../editor-event-adapter'; import { Editor, EditorContainer } from './ui/editor'; export function PlateEditor() { const editor = usePlateEditor({ plugins: EditorKit, }); + const editorAdapter = React.useMemo(() => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const adapter = new PlateEditorAdapter(editor as any, 'draftId'); + + // 임시 change handler 설정 (추후 mutate API 연결) + adapter.onContentChange((request) => { + toast(`📡 [${request.type}]`); + }); + + return adapter; + }, [editor]); + return ( - + { + editorAdapter.processOperations(operations); + }} + >