import { IJodit } from 'jodit/src/types'; import { Jodit } from 'jodit/src/index'; import { Dom, Plugin } from 'jodit/src/modules'; import { $$ } from 'jodit/src/core/helpers'; import { KEY_ENTER, KEY_ESC, KEY_SPACE } from 'jodit/src/core/constants'; import './hash-tag.less'; export class HashTag extends Plugin { private ESCAPE_TRIGGER_KEYS: readonly string[] = [ KEY_ENTER, KEY_SPACE, KEY_ESC ]; private ESCAPE_TRIGGER_EVENTS: readonly string[] = [ 'focus', 'blur', 'paste', 'beforeEnter' ]; private HASH_TAG_MAX_LENGTH = 20 as const; private hashTagRegex: RegExp = /[a-zA-Z0-9가-힇ㄱ-ㅎㅏ-ㅣぁ-ゔァ-ヴー々〆〤一-龥]/; private hashTagRegexWithGlobalFlag: RegExp = new RegExp( this.hashTagRegex, 'g' ); private HASH_TAG_CLASS_NAME = 'hash-tag' as const; private FAKE_ELEMENT_CLASS_NAME = 'jodit-composing-hash-tag' as const; /** * @returns 해시태그를 입력중인지 여부를 반환합니다. */ private isEditingHashTag(): boolean { const range = this.j.s.sel?.rangeCount ? this.j.s.sel?.getRangeAt(0) : null; return Dom.isClassName( range?.startContainer?.parentElement, this.FAKE_ELEMENT_CLASS_NAME ); } /** * - 키보드 이벤트를 통해 해시태그를 입력할 준비를 합니다. */ protected prepareHashTag(e: KeyboardEvent): void { const isHashTag = e.key === '#'; if (!this.isEditingHashTag() && isHashTag && !e.isComposing) { e.preventDefault(); this.createFakeElementForHashTag(); } } /** * - ESCAPE_TRIGGER_EVENTS에 포함된 이벤트와 keydown이벤트를 수신합니다. * - (한글 입력중에는 keydown 이벤트를 수신하지 못하여 addEventListener를 사용하여 별도로 수신합니다.) * - keydown - ESCAPE_TRIGGER_KEYS에 포함된 키를 누르면 해시태그 입력을 완료합니다. * - ESCAPE_TRIGGER_EVENTS - 해시태그 입력을 완료합니다. * @param e - 키보드 이벤트 또는 포커스 이벤트 */ protected escapeFromFakeElement(e: KeyboardEvent | FocusEvent): void { if ($$(`.${this.FAKE_ELEMENT_CLASS_NAME}`, this.j.editor)?.length === 0) return; /** * - 해시태그 입력을 위한 임시 엘리먼트에서 벗어납니다. * - hashTagComplete 이벤트를 발생시킵니다. */ const escape = (): void => { const hashTagFakeElement = $$( `.${this.FAKE_ELEMENT_CLASS_NAME}`, this.j.editor )?.[0]; this.j.s.setCursorAfter(hashTagFakeElement); this.j.e.fire('hashTagComplete'); }; if (e?.type === 'keydown') { if (this.ESCAPE_TRIGGER_KEYS.includes((e as KeyboardEvent)?.code)) { /** * - IME가 활성화된 상태(isComposing)에서 키 이벤트가 발생하면 onCompositionEnd 이벤트를 수신합니다. * - onCompositionEnd 이벤트가 발생하면 escape 함수를 호출한 후 onCompositionEnd 이벤트를 제거합니다. */ if ((e as KeyboardEvent)?.isComposing) { const onCompositionEnd = (): void => { escape(); this.j.editor.removeEventListener( 'compositionend', onCompositionEnd ); }; this.j.editor.addEventListener( 'compositionend', onCompositionEnd ); return; } escape(); return; } } /** * - ESCAPE_TRIGGER_EVENTS에 포함된 이벤트가 발생하면 escape 함수를 호출합니다. */ if (this.ESCAPE_TRIGGER_EVENTS.includes(e?.type)) { escape(); return; } } /** * - 해시태그 입력을 완료합니다. * - hashTagComplete 이벤트 수신시 호출됩니다. */ private onHashTagComplete(): void { const fakeElement = $$( `.${this.FAKE_ELEMENT_CLASS_NAME}`, this.j.editor )?.[0]; if (!fakeElement) return; const hashTagContent = fakeElement?.innerText?.trim() ?? ''; Dom.safeRemove(fakeElement); this.createHashTagElement(hashTagContent); } /** * - 해시태그 입력을 위한 임시 엘리먼트를 생성합니다. * - 새로운 해시태그를 입력할때는 '#'를 표시하고, 기존 해시태그를 수정할때는 기존 해시태그의 내용을 표시합니다. * @param text - 해시태그 수정시 기존 해시태그 텍스트 */ createFakeElementForHashTag(text?: string): void { const fakeElement = this.j.c.span( this.FAKE_ELEMENT_CLASS_NAME, text ? text : '#' ); this.j.s.insertNode(fakeElement); this.j.s.setCursorIn(fakeElement, false); } /** * - 해시태그 내용을 가공하여 엘리먼트를 생성합니다. * - 공백과 특수문자를 지우고, Max length를 초과하면 텍스트를 자릅니다. * - 잘린 나머지 텍스트는 기존 형식을 유지하며, 일반 텍스트로 추가됩니다. * @param text - 해시태그 내용 텍스트 */ createHashTagElement(text: string): void { const tagText = text.replace('#', ''); if (!tagText) return; let convertedText = tagText .match(this.hashTagRegexWithGlobalFlag) ?.join(''); if (!convertedText) return; let restText; if (convertedText.length > this.HASH_TAG_MAX_LENGTH) { convertedText = convertedText.slice(0, this.HASH_TAG_MAX_LENGTH); const charArray = tagText.split(''); let splitIndex = 0; let validCharCount = 0; /** * 원본 tagText를 배열로 바꾼 charArray를 순회하며 특수문자를 제외한 문자들중 MAX_LENGTH를 초과하는 시점의 인덱스를 찾습니다. * 찾은 인덱스를 바탕으로 잘린 나머지 텍스트를 restText에 저장합니다. * restText는 원본 형식을 유지하며, 일반 텍스트로 추가됩니다. */ for (let i = 0; i < charArray.length; i++) { const char = charArray[i]; const isValidChar = this.hashTagRegex.test(char); if (isValidChar) { validCharCount++; } if (validCharCount === this.HASH_TAG_MAX_LENGTH) { splitIndex = i; break; } } restText = tagText.slice(splitIndex + 1); } const hashTagElement = this.j.c.span( this.HASH_TAG_CLASS_NAME, `#${convertedText}` ); /** * - 해시태그의 내용을 data-hash-tag 속성에 저장합니다.(for backend) */ hashTagElement.setAttribute('data-hash-tag', convertedText); this.j.s.insertNode(hashTagElement); this.j.s.insertHTML(' '); if (restText) { this.j.s.insertHTML(restText); } } /** * 해시태그 엘리먼트를 임시 엘리먼트로 변경합니다. * @param node - 해시태그 엘리먼트 */ private changeHashTagIntoFakeElement(node?: Node): void { if (!node) return; if (!Dom.isClassName(node, this.HASH_TAG_CLASS_NAME)) return; const text = node.textContent?.trim() ?? ''; Dom.safeRemove(node); this.createFakeElementForHashTag(text); } /** * 해시태그 내용이 변경되었을 때 호출됩니다. * changeHashTagIntoFakeElement를 호출하여 해시태그 엘리먼트를 임시 엘리먼트로 변경합니다. */ private onChangeHashTag(): void { const range = this.j.s.sel?.rangeCount ? this.j.s.sel?.getRangeAt(0) : null; const isHashTagChanged = Dom.isClassName( range?.startContainer?.parentNode, this.HASH_TAG_CLASS_NAME ); if (!isHashTagChanged) return; this.changeHashTagIntoFakeElement( range?.startContainer?.parentNode as Node ); } protected override afterInit(jodit: IJodit): void { /** * this.j.e.on이나 watch 데코레이터를 사용하면 한글 입력중에 이벤트를 수신하지 못해 addEventListener를 사용 */ jodit.editor.addEventListener('keydown', e => { this.prepareHashTag.bind(this)(e); this.escapeFromFakeElement.bind(this)(e); }); this.j.e .on('hashTagComplete', this.onHashTagComplete.bind(this)) .on( this.ESCAPE_TRIGGER_EVENTS.join(' '), this.escapeFromFakeElement.bind(this) ) .on('change.hashTag', this.onChangeHashTag.bind(this)); } protected override beforeDestruct(jodit: IJodit): void { jodit.editor.removeEventListener('keydown', e => { this.prepareHashTag.bind(this)(e); this.escapeFromFakeElement.bind(this)(e); }); this.j.e .off('hashTagComplete', this.onHashTagComplete.bind(this)) .off( this.ESCAPE_TRIGGER_EVENTS.join(' '), this.escapeFromFakeElement.bind(this) ) .off('change.hashTag', this.onChangeHashTag.bind(this)); } } Jodit.plugins.add('hashTag', HashTag);