Skip to content

Mention

@jennifersoft/apm-components의 멘션(@사용자) 모듈 문서입니다. 이 문서는 @jennifersoft/apm-components@1.4.1 기준으로 정리했습니다.

textarea에서 @를 입력했을 때 사용자 자동완성을 띄우고, 저장 시에는 이름이 아닌 id sentinel로 보존하는 유틸 모음입니다. 컴포넌트가 아니라 composable + 순수 함수입니다.

인터랙티브 데모

state.visibletrue인 동안 ↑↓/Enter를 누르면 handleKeydowntrue를 반환합니다. 위 로그에서 가로챔 표시를 확인해 보세요. 이 값을 무시하면 멘션 선택 시 폼이 전송됩니다.

이름 치환 규칙 확인

replaceMentionSentinelsWithNames()가 형식별로 다르게 동작하는 것을 볼 수 있습니다. 디렉터리에 있는 id는 hong.gildong, kim.younghee 등이고 unknown.person, nobody는 없습니다.

createMentionSentinel / isWordBoundaryBefore

저장 형식

멘션은 화면에 @홍길동으로 보이지만, 저장되는 원문은 id 기반 sentinel입니다.

[~hong.gildong]

이름이 바뀌어도 참조가 깨지지 않게 하기 위해서입니다. 표시 직전에 replaceMentionSentinelsWithNames()로 현재 이름을 채웁니다.

레거시 데이터는 @id 형태로도 존재하며, 두 형식을 모두 인식합니다.

typescript
const SENTINEL_ID_RE: RegExp;  // [~id] 형식
const LEGACY_ID_RE: RegExp;    // @id 형식

id로 허용되는 문자는 영숫자·언더스코어·한글이며, 중간에 .-를 포함할 수 있습니다 (단, 처음과 끝은 불가).

useMentionAutocomplete

textarea 기반 멘션 자동완성 composable입니다.

옵션

typescript
interface UseMentionAutocompleteOptions {
    /** query가 바뀔 때마다 호출되는 디바운스 간격(ms). 기본 200 */
    debounceMs?: number;
    /** 텍스트 변경 시 콜백 (v-model 업데이트용) */
    onUpdate: (value: string) => void;
    /** 후보 검색 함수. 앱마다 다른 API를 주입한다 */
    search: (query: string) => Promise<readonly MentionCandidate[]>;
    /** textarea HTMLElement (또는 동일 shape의 mock) ref */
    textareaRef: () => MentionTextareaLike | null;
}

interface MentionCandidate {
    id: string;
    name: string;
}

interface MentionTextareaLike {
    focus(): void;
    selectionStart: number;
    setSelectionRange(start: number, end: number): void;
    value: string;
}

search를 주입받는 구조라 앱마다 다른 사용자 조회 API를 붙일 수 있고, 테스트에서는 MentionTextareaLike mock으로 DOM 없이 검증할 수 있습니다.

반환값

typescript
const { state, handleInput, handleKeydown, selectUser, close } =
    useMentionAutocomplete(options);
항목타입설명
stateRef<MentionAutocompleteState>팝업 상태
handleInput() => voidtextarea input에 연결. 텍스트를 갱신하지 않음
handleKeydown(e: MentionKeyboardEventLike) => boolean가로챘으면 true
selectUser(user: MentionCandidate) => void후보 클릭 시
close() => void팝업 닫기
typescript
interface MentionAutocompleteState {
    mentionStart: number;   // @ 시작 위치 (없으면 -1)
    query: string;
    selectedIndex: number;
    users: MentionCandidate[];
    visible: boolean;
}

textarea는 반드시 v-model로 묶으세요

WARNING

handleInput()onUpdate를 호출하지 않습니다. DOM의 textarea.valueselectionStart를 읽어 멘션 상태(state)만 갱신합니다. onUpdate가 호출되는 시점은 selectUser()가 sentinel을 삽입할 때뿐입니다.

따라서 textarea를 :value="text"처럼 단방향으로 묶으면 타이핑한 글자가 사라집니다.state가 바뀌어 리렌더될 때 낡은 text로 DOM이 되돌아가기 때문입니다.

vue
<!-- 잘못된 예: 타이핑이 리렌더에 지워진다 -->
<textarea :value="text" @input="handleInput" />

<!-- 올바른 예: v-model 로 타이핑을 반영하고, handleInput 은 멘션 상태만 추적 -->
<textarea v-model="text" @input="handleInput" />

v-model이 본문을 소유하고, onUpdate컴포저블이 본문을 바꿀 때만 되돌려받는 통로입니다.

handleKeydown의 반환값

IMPORTANT

handleKeydowntrue를 반환하면 자동완성이 그 키를 소비했다는 뜻입니다. 호출부는 preventDefault()를 호출하고 자체 처리(예: 질문 전송)를 건너뛰어야 합니다. 이를 무시하면 Enter로 멘션을 고를 때마다 폼이 전송됩니다.

typescript
function onKeydown(event: KeyboardEvent) {
    if (handleKeydown(event)) {
        event.preventDefault();
        return;
    }

    if (event.key === 'Enter' && !event.shiftKey) {
        submit();
    }
}

IME 조합 처리

한글 등 IME 조합 중에는 Vue v-model이 값을 갱신하지 않습니다(조합 완료 시에만 commit). 그래서 이 composable은 DOM의 textarea.valueselectionStart를 진실의 원천으로 삼습니다.

MentionKeyboardEventLike.isComposing을 확인해 조합 중 키 입력은 가로채지 않습니다.

typescript
interface MentionKeyboardEventLike {
    isComposing?: boolean;
    key: string;
    keyCode?: number;
}

stale 응답 방어

검색은 debounceMs(기본 200ms)로 디바운스되며, 응답이 도착했을 때 현재 query와 다르면 결과를 버립니다. 빠르게 타이핑할 때 이전 요청의 결과가 뒤늦게 덮어쓰는 문제가 발생하지 않습니다. 검색 실패 시에도 목록만 비우고 예외를 던지지 않습니다.

사용 예제

vue
<script setup lang="ts">
import { ref } from 'vue';
import { useMentionAutocomplete } from '@jennifersoft/apm-components';
import type { MentionCandidate } from '@jennifersoft/apm-components';

const text = ref('');
const textareaRef = ref<HTMLTextAreaElement | null>(null);

const { state, handleInput, handleKeydown, selectUser, close } =
    useMentionAutocomplete({
        onUpdate: (value) => (text.value = value),
        search: (query) => userApi.searchMembers(query),
        textareaRef: () => textareaRef.value,
        debounceMs: 200,
    });

function onKeydown(event: KeyboardEvent) {
    if (handleKeydown(event)) {
        event.preventDefault();
        return;
    }
    if (event.key === 'Enter' && !event.shiftKey) {
        event.preventDefault();
        submit();
    }
}
</script>

<template>
    <div class="mention-field">
        <textarea
            ref="textareaRef"
            v-model="text"
            @input="handleInput"
            @keydown="onKeydown"
            @blur="close"
        />

        <ul v-if="state.visible" class="mention-popup">
            <li
                v-for="(user, index) in state.users"
                :key="user.id"
                :class="{ selected: index === state.selectedIndex }"
                @mousedown.prevent="selectUser(user)"
            >
                {{ user.name }}
            </li>
        </ul>
    </div>
</template>

후보 클릭에 @click이 아니라 @mousedown.prevent를 쓰는 이유는, blur가 먼저 발생해 팝업이 닫히면 클릭이 사라지기 때문입니다.

포맷 유틸

createMentionSentinel

typescript
function createMentionSentinel(id: string): string;

자동완성에서 사용자를 선택했을 때 textarea에 삽입할 sentinel 텍스트를 만듭니다. selectUser()가 내부에서 사용하므로 보통 직접 호출할 일은 없습니다.

extractMentionIds

typescript
function extractMentionIds(text: string): readonly string[];

텍스트 안의 멘션 id를 중복 없이 모두 추출합니다. 이름 일괄 조회에 사용합니다.

typescript
const ids = extractMentionIds(comment.body);
const names = await userApi.getNames(ids);

replaceMentionSentinelsWithNames

typescript
function replaceMentionSentinelsWithNames(
    text: string,
    resolveName: (id: string) => string | undefined
): string;

sentinel을 표시용 이름으로 치환합니다. 두 번째 인자는 Map이 아니라 id를 이름으로 바꾸는 함수입니다. 이름을 모르면 undefined를 반환하면 됩니다.

typescript
const nameById = new Map(users.map((u) => [u.id, u.name]));

const display = replaceMentionSentinelsWithNames(
    comment.body,
    (id) => nameById.get(id)
);

알 수 없는 값의 처리 규칙이 형식마다 다릅니다.

입력이름을 알 때이름을 모를 때
[~id] (sentinel)@이름@id
@id (legacy)@이름원문 그대로

레거시를 원문 유지하는 이유는, 디렉터리에 없는 @문자열이 실제로는 멘션이 아닌 평범한 텍스트일 수 있기 때문입니다(방어적 처리).

isWordBoundaryBefore

typescript
function isWordBoundaryBefore(text: string, index: number): boolean;

legacy @id가 멘션으로 인정되려면 @ 앞이 공백이거나 문자열 시작이어야 합니다. user@example.com 같은 이메일이 멘션으로 오인되지 않게 합니다.

주의사항

  • 저장은 sentinel 원문으로, 표시는 치환 후로 분리하세요. 이름을 저장하면 개명 시 깨집니다.
  • handleKeydown의 반환값을 반드시 확인하세요.
  • 후보 목록은 mousedown으로 처리해야 blur와 경합하지 않습니다.
  • search는 실패해도 예외를 밖으로 던지지 않습니다. 오류 표시가 필요하면 주입 함수 안에서 처리하세요.