Before Hooks 사용 사례 분석
개요
Extension의 onBeforeTransaction, onBeforeSelectionChange 같은 Before hooks를 통해 transaction/selection/content 변경을 가로채서 수정하거나 취소할 수 있는 실제 사용 사례들을 정리합니다.
1. Read-Only 모드 강제
문제
- 현재
editorEditablecontext로 keybinding만 비활성화됨 - 하지만
editor.executeCommand()로 직접 호출하면 우회 가능 - Transaction 자체를 막을 수 없음
해결: onBeforeTransaction
class ReadOnlyExtension implements Extension {
onCreate(editor: Editor) {
editor.setContext('readOnly', true);
}
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
// Read-only 모드면 모든 transaction 취소
if (editor.getContext('readOnly')) {
return null; // 취소
}
return transaction; // 그대로 진행
}
}
효과
- Command 우회 불가능
- 모든 transaction 차단
- 일관된 read-only 보장
2. Content Sanitization (XSS 방지)
문제
- 외부에서 paste된 HTML에
<script>태그 포함 가능 - 사용자 입력에 악성 코드 포함 가능
해결: onBeforeTransaction
class SanitizeExtension implements Extension {
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
const ops = transaction.getOperations();
// insertText operation에서 악성 패턴 검사
for (const op of ops) {
if (op.type === 'insertText') {
const sanitized = this.sanitizeText(op.text);
if (sanitized !== op.text) {
// 수정된 operation으로 교체
return transaction.withOperation(
op.id,
{ ...op, text: sanitized }
);
}
}
}
return transaction;
}
private sanitizeText(text: string): string {
// <script> 태그 제거, HTML 엔티티 이스케이프 등
return text.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '');
}
}
효과
- 모든 텍스트 입력 자동 검사
- 악성 코드 자동 제거
- 안전한 content 보장
3. Auto-Formatting (자동 포맷팅)
문제
- 사용자가 URL을 입력하면 자동으로 링크로 변환
- 날짜 입력 시 자동 포맷팅
- 전화번호 자동 포맷팅
해결: onBeforeTransaction
class AutoFormatExtension implements Extension {
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
const ops = transaction.getOperations();
const newOps: Operation[] = [];
for (const op of ops) {
if (op.type === 'insertText') {
// URL 패턴 감지
const urlMatch = op.text.match(/https?:\/\/[^\s]+/);
if (urlMatch) {
// 텍스트 삽입 + 링크 마크 추가로 변환
newOps.push(op);
newOps.push({
type: 'addMark',
nodeId: op.nodeId,
mark: { type: 'link', attributes: { href: urlMatch[0] } },
range: [op.offset, op.offset + urlMatch[0].length]
});
continue;
}
}
newOps.push(op);
}
// operation이 변경되었으면 새 transaction 반환
if (newOps.length !== ops.length) {
return transaction.withOperations(newOps);
}
return transaction;
}
}
효과
- 사용자 입력 자동 변환
- 일관된 포맷팅
- 사용자 경험 향상
4. Change Tracking (변경 추적)
문제
- 누가 무엇을 언제 변경했는지 추적 필요
- 변경 이력에 메타데이터 추가 필요
해결: onBeforeTransaction
class ChangeTrackingExtension implements Extension {
private userId: string;
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
// Transaction에 메타데이터 추가
return transaction.withMetadata({
userId: this.userId,
timestamp: Date.now(),
changeType: this.detectChangeType(transaction)
});
}
private detectChangeType(transaction: Transaction): string {
const ops = transaction.getOperations();
if (ops.some(op => op.type === 'insertText')) return 'insert';
if (ops.some(op => op.type === 'deleteText')) return 'delete';
if (ops.some(op => op.type === 'updateNode')) return 'format';
return 'unknown';
}
}
효과
- 모든 변경에 메타데이터 자동 추가
- 변경 이력 추적 가능
- 협업 편집 지원
5. Collaborative Editing Protection
문제
- 사용자가 입력 중인 노드를 외부 변경으로부터 보호 필요
- AI나 다른 사용자의 변경이 사용자 입력을 방해
해결: onBeforeTransaction
class CollaborativeProtectionExtension implements Extension {
private activeTextNodeId: string | null = null;
onCreate(editor: Editor) {
// 사용자 입력 시작 감지
editor.on('editor:content.change', () => {
this.activeTextNodeId = editor.selection?.startNodeId || null;
});
}
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
// 외부 변경인지 확인 (사용자 입력이 아닌 경우)
const isExternalChange = !transaction.metadata?.isUserInput;
if (isExternalChange && this.activeTextNodeId) {
// 활성 노드에 대한 변경인지 확인
const ops = transaction.getOperations();
const affectsActiveNode = ops.some(op =>
op.nodeId === this.activeTextNodeId
);
if (affectsActiveNode) {
// 사용자 입력 중인 노드는 보호
console.warn('[CollaborativeProtection] Blocked external change to active node');
return null; // 취소
}
}
return transaction;
}
}
효과
- 사용자 입력 보호
- 외부 변경으로부터 안전
- 협업 편집 안정성 향상
6. Selection Normalization (선택 영역 정규화)
문제
- Selection이 특정 규칙을 위반 (예: 블록 노드 내부만 선택 가능)
- Selection이 비정상적인 위치 (예: 데코레이터 영역)
해결: onBeforeSelectionChange
class SelectionNormalizeExtension implements Extension {
onBeforeSelectionChange(
editor: Editor,
selection: SelectionState
): SelectionState | null {
// Selection이 블록 노드 경계를 넘지 않도록 정규화
const normalized = this.normalizeSelection(editor, selection);
if (normalized.startNodeId !== selection.startNodeId ||
normalized.startOffset !== selection.startOffset) {
// Selection이 변경되었으면 정규화된 selection 반환
return normalized;
}
return selection; // 그대로 진행
}
private normalizeSelection(
editor: Editor,
selection: SelectionState
): SelectionState {
// 블록 노드 경계 확인 및 조정 로직
// ...
return selection;
}
}
효과
- 일관된 selection 보장
- 비정상적인 selection 방지
- 사용자 경험 향상
7. Transaction Filtering (트랜잭션 필터링)
문제
- 특정 조건의 transaction만 허용
- 예: 특정 노드 타입만 수정 가능
해결: onBeforeTransaction
class TransactionFilterExtension implements Extension {
private allowedNodeTypes: string[] = ['paragraph', 'heading'];
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
const ops = transaction.getOperations();
// 허용되지 않은 노드 타입 수정 시도 차단
for (const op of ops) {
if (op.type === 'updateNode' || op.type === 'createNode') {
const nodeType = op.stype || op.attributes?.stype;
if (nodeType && !this.allowedNodeTypes.includes(nodeType)) {
console.warn(`[TransactionFilter] Blocked operation on ${nodeType}`);
return null; // 취소
}
}
}
return transaction;
}
}
효과
- 세밀한 권한 제어
- 특정 노드 타입만 수정 가능
- 보안 강화
8. Content Validation (컨텐츠 검증)
문제
- Schema 검증을 통과했지만 비즈니스 로직 검증 필요
- 예: 최대 글자 수, 특정 패턴 필수
해결: onBeforeTransaction
class ContentValidationExtension implements Extension {
private maxLength: number = 1000;
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
const ops = transaction.getOperations();
for (const op of ops) {
if (op.type === 'insertText') {
const node = editor.dataStore.getNode(op.nodeId);
const currentLength = node?.text?.length || 0;
const newLength = currentLength + op.text.length;
if (newLength > this.maxLength) {
// 최대 길이 초과 시 취소
console.warn(`[ContentValidation] Text too long: ${newLength} > ${this.maxLength}`);
return null;
}
}
}
return transaction;
}
}
효과
- Schema 검증 이후 추가 검증
- 비즈니스 로직 강제
- 데이터 무결성 보장
9. Auto-Correction (자동 수정)
문제
- 오타 자동 수정
- 대소문자 자동 수정
해결: onBeforeTransaction
class AutoCorrectExtension implements Extension {
private corrections: Map<string, string> = new Map([
['teh', 'the'],
['adn', 'and'],
// ...
]);
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
const ops = transaction.getOperations();
const newOps: Operation[] = [];
for (const op of ops) {
if (op.type === 'insertText') {
// 오타 수정
let correctedText = op.text;
for (const [wrong, correct] of this.corrections) {
correctedText = correctedText.replace(wrong, correct);
}
if (correctedText !== op.text) {
// 수정된 텍스트로 교체
newOps.push({ ...op, text: correctedText });
continue;
}
}
newOps.push(op);
}
if (newOps.length !== ops.length ||
newOps.some((op, i) => op !== ops[i])) {
return transaction.withOperations(newOps);
}
return transaction;
}
}
효과
- 실시간 오타 수정
- 사용자 경험 향상
- 일관된 텍스트 품질
10. Transaction Batching (트랜잭션 배칭)
문제
- 빠른 연속 입력 시 transaction이 너무 많음
- 성능 저하 및 이벤트 폭주
해결: onBeforeTransaction
class TransactionBatchingExtension implements Extension {
private pendingOps: Operation[] = [];
private batchTimeout: number | null = null;
onBeforeTransaction(editor: Editor, transaction: Transaction): Transaction | null {
const ops = transaction.getOperations();
// 배치에 추가
this.pendingOps.push(...ops);
// 타임아웃 설정 (100ms 내 추가 transaction이 없으면 배치 실행)
if (this.batchTimeout) {
clearTimeout(this.batchTimeout);
}
this.batchTimeout = window.setTimeout(() => {
// 배치 실행
const batchedTransaction = transaction.withOperations(this.pendingOps);
this.pendingOps = [];
this.batchTimeout = null;
// 실제 transaction 실행 (재귀 방지를 위해 플래그 설정)
editor.executeTransaction(batchedTransaction);
}, 100);
// 현재 transaction은 취소 (배치에서 처리)
return null;
}
}
효과
- Transaction 수 감소
- 성능 향상
- 이벤트 폭주 방지
구현 방식 요약
Before Hooks 시그니처
interface Extension {
// Transaction 가로채기
onBeforeTransaction?(
editor: Editor,
transaction: Transaction
): Transaction | null | void;
// - Transaction 반환: 수정된 transaction 사용
// - null 반환: transaction 취소
// - void: 그대로 진행
// Selection 가로채기
onBeforeSelectionChange?(
editor: Editor,
selection: SelectionState
): SelectionState | null | void;
// - Selection 반환: 다른 selection으로 교체
// - null 반환: selection 변경 취소
// - void: 그대로 진행
// Content 가로채기
onBeforeContentChange?(
editor: Editor,
content: DocumentState
): DocumentState | null | void;
// - Content 반환: 다른 content로 교체
// - null 반환: content 변경 취소
// - void: 그대로 진행
}
실행 순서
- Extension들을
priority순으로 정렬 - 각 Extension의
onBeforeTransaction호출 - 반환값이
null이면 즉시 취소 - 반환값이
Transaction이면 수정된 transaction 사용 - 모든 Extension 통과 후 실제 transaction 실행
결론
Before hooks는 다음과 같은 상황에서 유용합니다:
- 보안: Read-only 강제, Content sanitization
- 자동화: Auto-formatting, Auto-correction
- 보호: Collaborative editing protection
- 검증: Content validation, Transaction filtering
- 최적화: Transaction batching
- 추적: Change tracking
이러한 기능들은 단순 이벤트 리스너로는 불가능하며, transaction/selection/content를 가로채서 수정하거나 취소할 수 있어야 구현 가능합니다.