feat(frontend): 임시 저장·서명 설정·받는 사람 자동 완성 화면

- 쓰기 화면 자동 저장(3초 멈춤 · 30초 · 10초 간격 · 한 번에 하나)과 상태 글, 저장할 때마다 주소를 ?draft= 로 맞춰
  새로고침해도 이어 쓴다. MAIL_008 · PUT 404 는 막지 않고 새 저장본으로 남긴다
- 찾지 못한 첨부가 남았거나 본문이 한도를 넘는 동안은 저장하지 않고, 실을 수 없는 첨부가 든 남의 저장본은 원본을 지우지 않는다
- 휴지통은 휴지통으로 옮기기(PATCH)와 되돌리기 — 이미 휴지통에 있는 메일을 DELETE 로 영구 삭제하지 않게
- 설정 › 메일 › 서명: 서식 · 그림(크기 줄) · 미리 보기 · 넣을 메일, 저장하지 않고 떠나면 묻는다
- 받는 사람 자동 완성: 사내 · 최근 묶음, 초성 검색(조합 중 글자 포함), 완전한 주소는 미리 고르지 않는다
- 본문에 그림으로 보인 첨부(isInline)는 읽기 화면의 첨부 목록에서 빼고 전달에는 붙인다

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 19:23:22 +09:00
parent a79f0791d0
commit 5710151c0b
88 changed files with 8717 additions and 418 deletions
@@ -0,0 +1,68 @@
<script setup lang="ts">
// 화면을 떠나기 전 확인 — 세션 만료 대화상자와 같은 모양 (ModalDialog · role=alertdialog)
// 쓰는 곳: 쓰기 화면(ComposeLeaveDialog — 저장하지 못한 내용) · 설정 › 메일 › 서명(저장하지 않은 서명)
// [머무르기](계속 쓰기 · 계속 고치기)가 처음 초점이고 Esc 도 같다. 쓰는 쪽이 답을 받아 이동을 취소하거나(머무르기) 그대로 보낸다(나가기).
// 단추는 오른쪽 정렬 — 머무르기는 채운 기본 단추 · 나가기는 보조 단추 (디자인 README "나가기 전 확인").
// 고정 id — {idPrefix}-dialog · {idPrefix}-title · {idPrefix}-message · {idPrefix}-stay · {idPrefix}-confirm
import ModalDialog from '@/components/feedback/ModalDialog.vue'
interface LeaveDialogCopy {
title: string
message: string
/** 머무르기 단추 */
stay: string
/** 나가기 단추 */
leave: string
}
interface Props {
open: boolean
/** 고정 id 의 앞부분 — 화면에 하나뿐인 값 */
idPrefix: string
copy: LeaveDialogCopy
}
interface Emits {
/** 머무르기 (Esc 포함) — 이동을 취소한다 */
(e: 'stay'): void
/** 나가기 — 바꾼 것을 버리고 이동한다 */
(e: 'leave'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<ModalDialog
:id="`${idPrefix}-dialog`"
:open="open"
role="alertdialog"
:title="copy.title"
:message="copy.message"
:title-id="`${idPrefix}-title`"
:message-id="`${idPrefix}-message`"
@cancel="emit('stay')"
>
<template #actions>
<!-- 처음 초점 — 실수로 Enter · Space 를 눌러도 바꾼 것이 남는다 -->
<button
:id="`${idPrefix}-stay`"
type="button"
class="modal-dialog__action"
autofocus
@click="emit('stay')"
>
{{ copy.stay }}
</button>
<button
:id="`${idPrefix}-confirm`"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('leave')"
>
{{ copy.leave }}
</button>
</template>
</ModalDialog>
</template>
@@ -1,6 +1,6 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · ComposeLeaveDialog(쓰기 화면 나가기 전 확인)
// 쓰는 곳: SessionExpiredDialog(세션 만료) · LeaveConfirmDialog(떠나기 전 확인 — 쓰기 화면 · 설정 › 서명)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
+33 -3
View File
@@ -1,11 +1,26 @@
<script setup lang="ts">
// 화면 오른쪽 아래 알림 (디자인 원본 States "오류 알림" · "보내기 완료")
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크
// 오류: 흰 카드 + 문의 코드(traceId 앞 8자리) + 닫기 / 성공: 브랜드 바탕 + 이동 링크 또는 그 자리의 단추(되돌리기 등)
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫히지 않는다 (toast.store 의 hold · release — WCAG 2.2.1)
import AppIcon from '@/components/AppIcon.vue'
import { formatTraceCode } from '@/shared/utils/format'
import { useToastStore } from '@/stores/toast.store'
import { useToastStore, type Toast } from '@/stores/toast.store'
const toastStore = useToastStore()
/** 초점이 알림 밖으로 나갔을 때만 놓는다 — 알림 안의 다른 단추로 옮긴 것은 그대로 붙잡는다 */
function onFocusOut(event: FocusEvent, id: number) {
const next = event.relatedTarget
if (next instanceof Node && event.currentTarget instanceof Node && event.currentTarget.contains(next)) return
toastStore.release(id, 'focus')
}
/** 그 자리의 단추 — 알림을 닫고 할 일을 한다 */
function runAction(toast: Toast) {
const action = toast.action
toastStore.dismiss(toast.id)
if (action && 'run' in action) action.run()
}
</script>
<template>
@@ -23,6 +38,10 @@ const toastStore = useToastStore()
:key="toast.id"
:class="['toast', `toast--${toast.kind}`]"
:role="toast.kind === 'error' ? 'alert' : 'status'"
@mouseenter="toastStore.hold(toast.id, 'pointer')"
@mouseleave="toastStore.release(toast.id, 'pointer')"
@focusin="toastStore.hold(toast.id, 'focus')"
@focusout="onFocusOut($event, toast.id)"
>
<AppIcon
:name="toast.kind === 'error' ? 'alert-circle' : 'check'"
@@ -38,13 +57,21 @@ const toastStore = useToastStore()
>문의 코드 {{ formatTraceCode(toast.traceId) }}</span>
</div>
<RouterLink
v-if="toast.action"
v-if="toast.action && 'to' in toast.action"
:to="toast.action.to"
class="toast__action"
@click="toastStore.dismiss(toast.id)"
>
{{ toast.action.label }}
</RouterLink>
<button
v-else-if="toast.action"
type="button"
class="toast__action"
@click="runAction(toast)"
>
{{ toast.action.label }}
</button>
<button
v-if="toast.kind === 'error'"
type="button"
@@ -140,8 +167,11 @@ const toastStore = useToastStore()
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-on-brand-link);
font: inherit;
font-weight: 600;
text-decoration: none;
}
@@ -0,0 +1,42 @@
<script setup lang="ts">
// 쓰기 화면의 칸 안내 띠 — 받는 사람 오류(MAIL_002) · 제목 길이(VAL_001) · 본문 길이(MAIL_007) (디자인 원본 Compose)
// 문구는 쓰기 화면이 에러 코드 사전에서 가져와 준다. 그 칸의 입력이 id 를 aria-describedby 로 가리킨다
import AppIcon from '@/components/AppIcon.vue'
interface Props {
/** 칸이 가리키는 id — 화면에 하나뿐인 값 */
id: string
/** 사전 문구 */
message: string
}
defineProps<Props>()
</script>
<template>
<p
:id="id"
class="compose-alert"
role="alert"
>
<AppIcon
name="alert-circle"
:size="15"
/>
<span>{{ message }}</span>
</p>
</template>
<style scoped>
.compose-alert {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
padding: var(--space-8) var(--content-inline);
border-bottom: var(--border-width) solid var(--color-danger-border);
background: var(--color-danger-surface);
color: var(--color-danger);
font-size: var(--font-size-label);
}
</style>
@@ -0,0 +1,135 @@
<script setup lang="ts">
// 쓰기 화면 아래 단추 줄의 임시 저장 상태 (디자인 원본 ComposeAssist · README "2단계 › 임시 저장") — 12px
// - 저장하는 중: "저장하는 중…" (#667085)
// - 저장됨: ✓ "오후 3:12에 임시 저장됨" (✓ #0E7C86)
// - 실패: "임시 저장하지 못했습니다" (#B42318) + [다시 시도]
// - 다른 곳에서 바뀐 저장본(MAIL_008): 정보 아이콘 "다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · 오후 3:14" (#7A5200)
// 상태 글은 role="status" 라 바뀌면 스크린리더가 읽는다. 아직 저장한 적이 없으면 아무것도 보이지 않는다.
// 고정 id — #compose-draft-status · 다시 시도 #compose-draft-retry
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { DraftSaveStatus } from '@/composables/useDraftSaving'
import type { IconName } from '@/shared/constants/icons'
import { DRAFT_STATUS_TEXT } from '@/shared/constants/mail'
import { formatDraftSavedTime } from '@/shared/utils/format'
interface Props {
status: DraftSaveStatus
/** 마지막으로 저장한 시각 (ISO 8601) */
savedAt: string | null
/** 다시 시도할 수 없다 (보내는 중 등) */
retryDisabled?: boolean
}
interface Emits {
(e: 'retry'): void
}
const props = withDefaults(defineProps<Props>(), {
retryDisabled: false,
})
const emit = defineEmits<Emits>()
const time = computed(() => (props.savedAt ? formatDraftSavedTime(props.savedAt) : ''))
const view = computed<{ icon: IconName; text: string; tone: 'muted' | 'saved' | 'danger' | 'warning' } | null>(() => {
switch (props.status) {
case 'saving':
return { icon: 'loader', text: DRAFT_STATUS_TEXT.saving, tone: 'muted' }
case 'saved':
return time.value ? { icon: 'check', text: DRAFT_STATUS_TEXT.saved(time.value), tone: 'saved' } : null
case 'replaced':
return { icon: 'info', text: DRAFT_STATUS_TEXT.replaced(time.value), tone: 'warning' }
case 'failed':
return { icon: 'alert-circle', text: DRAFT_STATUS_TEXT.failed, tone: 'danger' }
default:
return null
}
})
</script>
<template>
<span class="draft-status">
<span
id="compose-draft-status"
:class="['draft-status__text', view ? `draft-status__text--${view.tone}` : null]"
role="status"
:data-status="status"
>
<template v-if="view">
<AppIcon
:name="view.icon"
:size="14"
:stroke-width="view.tone === 'saved' ? 2.2 : 1.9"
:class="['draft-status__icon', { 'draft-status__icon--saved': view.tone === 'saved' }]"
/>
<span>{{ view.text }}</span>
</template>
</span>
<button
v-if="status === 'failed'"
id="compose-draft-retry"
type="button"
class="draft-status__retry"
:disabled="retryDisabled"
@click="emit('retry')"
>
{{ DRAFT_STATUS_TEXT.retry }}
</button>
</span>
</template>
<style scoped>
.draft-status {
display: inline-flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2) var(--space-4);
min-width: 0;
}
.draft-status__text {
display: inline-flex;
align-items: flex-start;
gap: var(--space-6);
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.draft-status__icon {
margin-top: var(--space-2);
}
.draft-status__icon--saved {
color: var(--color-brand);
}
.draft-status__text--danger {
color: var(--color-danger);
}
.draft-status__text--warning {
color: var(--color-warning-text);
}
.draft-status__retry {
min-height: var(--size-touch);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-brand);
font-size: var(--font-size-label);
font-weight: 600;
}
.draft-status__retry:hover:not(:disabled) {
background: var(--color-surface-muted);
}
.draft-status__retry:disabled {
opacity: 0.6;
cursor: not-allowed;
}
</style>
@@ -0,0 +1,136 @@
<script setup lang="ts">
// 쓰기 화면의 아래 단추 줄 — [보내기] [임시 저장] + 임시 저장 상태 글 + 보내는 사람 (디자인 원본 Compose · ComposeAssist)
// 「취소」는 없다 — 머리의 닫기(✕)가 저장한 채 닫는다. [보내기]는 쓰기 화면의 form 을 제출한다(type="submit").
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 보내기 .compose__send · 임시 저장 #compose-save-draft ·
// 상태 #compose-draft-status(ComposeDraftStatus) · 줄 .compose__footer
import AppIcon from '@/components/AppIcon.vue'
import ComposeDraftStatus from '@/components/mail/ComposeDraftStatus.vue'
import type { DraftSaveStatus } from '@/composables/useDraftSaving'
interface Props {
canSend: boolean
sending: boolean
canSaveDraft: boolean
draftStatus: DraftSaveStatus
/** 마지막으로 저장한 시각 (ISO 8601) */
draftSavedAt: string | null
/** 보내는 사람 — "이름 <주소>" (비면 숨긴다) */
sender: string
}
interface Emits {
/** 「임시 저장」 · 상태 글의 [다시 시도] */
(e: 'save-draft'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<div class="compose__footer">
<button
type="submit"
class="compose__send"
:disabled="!canSend"
:aria-busy="sending"
>
<AppIcon
name="send"
:size="15"
:stroke-width="2"
/>
<span>{{ sending ? '보내는 중…' : '보내기' }}</span>
</button>
<button
id="compose-save-draft"
type="button"
class="compose__save"
:disabled="!canSaveDraft"
@click="emit('save-draft')"
>
임시 저장
</button>
<ComposeDraftStatus
:status="draftStatus"
:saved-at="draftSavedAt"
:retry-disabled="!canSaveDraft"
@retry="emit('save-draft')"
/>
<span
v-if="sender"
class="compose__sender"
>
<span class="compose__sender-label">보내는 사람</span>
{{ sender }}
</span>
</div>
</template>
<style scoped>
.compose__footer {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8);
padding: var(--space-10) var(--content-inline);
border-top: var(--border-width) solid var(--color-border);
}
.compose__send {
display: inline-flex;
align-items: center;
gap: var(--space-8);
min-height: var(--size-touch);
padding: 0 var(--space-20);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.compose__send:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.compose__send:disabled {
opacity: 0.6;
cursor: not-allowed;
}
/* 「임시 저장」 — 보조 단추 (디자인 원본 ComposeAssist) */
.compose__save {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-14);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.compose__save:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.compose__save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.compose__sender {
margin-left: auto;
font-size: var(--font-size-small);
color: var(--color-text-muted);
overflow-wrap: anywhere;
}
.compose__sender-label {
margin-right: var(--space-6);
}
</style>
@@ -0,0 +1,99 @@
<script setup lang="ts">
// 쓰기 화면의 머리 — 제목(쓰기 종류) · 휴지통 · 닫기 (디자인 원본 ComposeAssist: 단추 사이 2)
// 휴지통은 저장본을 휴지통으로 옮기고 닫는다(쓰기 화면이 한다), 닫기는 저장한 채 닫는다 — 떠나기 전에 남은 변경을 저장한다.
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 제목 #compose-title · 휴지통 #compose-discard · 닫기 .compose__close
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import { DRAFT_DISCARD_LABEL } from '@/shared/constants/mail'
interface Props {
title: string
/** 휴지통을 누를 수 없다 — 버리는 중 · 보내는 중 · 저장본을 불러오는 중 */
discardDisabled: boolean
/** 닫으면 갈 곳 — 보던 화면 */
closeTo: RouteLocationRaw
}
interface Emits {
(e: 'discard'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<div class="compose__header">
<h1
id="compose-title"
class="compose__title"
>
{{ title }}
</h1>
<!-- 저장본을 휴지통으로 옮기고 닫는다 (디자인 원본 ComposeAssist "임시 저장본 삭제") -->
<button
id="compose-discard"
type="button"
class="compose__icon-button"
:aria-label="DRAFT_DISCARD_LABEL"
title="삭제"
:disabled="discardDisabled"
@click="emit('discard')"
>
<AppIcon name="trash" />
</button>
<!-- 닫기는 저장한 채 닫는다 — 떠나기 전에 남은 변경을 저장한다 -->
<RouterLink
:to="closeTo"
class="compose__icon-button compose__close"
aria-label="쓰기 닫기"
title="닫기"
>
<AppIcon name="x" />
</RouterLink>
</div>
</template>
<style scoped>
.compose__header {
display: flex;
align-items: center;
gap: var(--space-2);
min-height: var(--size-header-min);
padding: var(--space-4) var(--space-18) var(--space-4) var(--content-inline);
border-bottom: var(--border-width) solid var(--color-border);
}
.compose__title {
flex: 1 1 auto;
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
letter-spacing: var(--letter-spacing-tight);
}
/* 머리의 아이콘 단추 — 휴지통 · 닫기 (44) */
.compose__icon-button {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--size-touch);
height: var(--size-touch);
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-secondary);
}
.compose__icon-button:hover:not(:disabled) {
background: var(--color-surface-muted);
color: var(--color-text);
}
.compose__icon-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>
@@ -1,9 +1,10 @@
<script setup lang="ts">
// 쓰기 화면 나가기 전 확인 — 디자인 README "나가기 전 확인" (세션 만료 대화상자와 같은 모양 · ModalDialog)
// 쓰기 화면 나가기 전 확인 — 디자인 README "2단계 › 임시 저장 › 나가기 확인" (공통 틀 LeaveConfirmDialog · ModalDialog)
// 떠나기 전에 남은 변경을 저장하고, 저장하지 못한 내용이 있을 때만 쓰기 화면이 연다.
// [계속 쓰기]가 처음 초점이고 Esc 도 같다. 쓰기 화면이 답을 받아 이동을 취소하거나(계속 쓰기) 그대로 보낸다(나가기).
// 고정 id — 화면 검증 · 테스트가 쓴다: 대화상자 #compose-leave-dialog · 제목 #compose-leave-title · 글 #compose-leave-message ·
// 단추 #compose-leave-stay(계속 쓰기) · #compose-leave-confirm(나가기)
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
interface Props {
@@ -13,7 +14,7 @@ interface Props {
interface Emits {
/** 계속 쓰기 (Esc 포함) — 이동을 취소한다 */
(e: 'stay'): void
/** 나가기 — 쓰던 내용을 버리고 이동한다 */
/** 나가기 — 저장하지 못한 내용을 버리고 이동한다 */
(e: 'leave'): void
}
@@ -22,35 +23,11 @@ const emit = defineEmits<Emits>()
</script>
<template>
<ModalDialog
id="compose-leave-dialog"
<LeaveConfirmDialog
:open="open"
role="alertdialog"
:title="COMPOSE_LEAVE_DIALOG.title"
:message="COMPOSE_LEAVE_DIALOG.message"
title-id="compose-leave-title"
message-id="compose-leave-message"
@cancel="emit('stay')"
>
<template #actions>
<!-- 처음 초점 — 실수로 Enter · Space 를 눌러도 쓰던 내용이 남는다 -->
<button
id="compose-leave-stay"
type="button"
class="modal-dialog__action"
autofocus
@click="emit('stay')"
>
{{ COMPOSE_LEAVE_DIALOG.stay }}
</button>
<button
id="compose-leave-confirm"
type="button"
class="modal-dialog__action modal-dialog__action--secondary"
@click="emit('leave')"
>
{{ COMPOSE_LEAVE_DIALOG.leave }}
</button>
</template>
</ModalDialog>
id-prefix="compose-leave"
:copy="COMPOSE_LEAVE_DIALOG"
@stay="emit('stay')"
@leave="emit('leave')"
/>
</template>
@@ -0,0 +1,90 @@
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
import { mount } from '@vue/test-utils'
import { createPinia, setActivePinia, type Pinia } from 'pinia'
import type { Mailbox, MessageSummary } from '@/shared/types/mail'
import { useAuthStore } from '@/stores/auth.store'
import { useMailStore } from '@/stores/mail.store'
import { restoreApiAdapter } from '@/test-utils/mock-api'
import { createTestRouter } from '@/test-utils/router'
import MessageList from './MessageList.vue'
const DRAFTS: Mailbox = {
id: 'drafts',
name: '임시보관함',
role: 'drafts',
parentId: null,
sortOrder: 3,
totalCount: 2,
unreadCount: 0,
}
function summary(id: string, partial: Partial<MessageSummary> = {}): MessageSummary {
return {
id,
threadId: `t-${id}`,
mailboxIds: [DRAFTS.id],
from: { name: '김아라', email: 'ara.kim@arasystem.test' },
to: [{ name: '이도윤', email: 'doyun.lee@arasystem.test' }],
subject: `제목 ${id}`,
preview: '',
receivedAt: '2025-10-24T01:00:00Z',
isRead: true,
isFlagged: false,
hasAttachment: false,
size: 100,
...partial,
}
}
describe('MessageList (분할 보기 목록)', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.test', name: '김아라' })
})
afterEach(() => {
restoreApiAdapter()
})
async function mountList(mailbox: Mailbox, messages: MessageSummary[]) {
const store = useMailStore()
store.mailboxes = [mailbox]
store.currentMailboxId = mailbox.id
store.messages = messages
store.pageInfo = { hasNext: false, nextCursor: null, total: messages.length }
store.listStatus = 'ready'
const router = createTestRouter()
await router.push(`/mail/${mailbox.id}`)
await router.isReady()
return mount(MessageList, { props: { mailboxId: mailbox.id }, global: { plugins: [pinia, router] } })
}
it('임시보관함 — 보낸 사람 자리에 받는 사람, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
const wrapper = await mountList(DRAFTS, [
summary('d1', { isRead: false, to: [{ name: '박준호', email: 'junho.park@arasystem.test' }, { name: '인프라팀', email: 'infra@arasystem.test' }] }),
summary('d2', { to: [], subject: '' }),
])
const rows = wrapper.findAll('.message-row')
expect(rows[0]?.get('.message-row__who').text()).toBe('박준호, 인프라팀')
expect(rows[0]?.classes()).not.toContain('is-unread')
expect(rows[0]?.get('.message-row__main').attributes('href')).toBe('/mail/compose?draft=d1')
expect(rows[1]?.get('.message-row__who').text()).toBe('(받는 사람 없음)')
expect(rows[1]?.get('.message-row__who').classes()).toContain('is-placeholder')
expect(rows[1]?.get('.message-row__subject').text()).toBe('(제목 없음)')
expect(rows[1]?.get('.message-row__subject').classes()).toContain('is-placeholder')
})
it('보낸편지함은 그대로 "받는 사람: …" 이고 읽기 화면으로 간다', async () => {
const sent: Mailbox = { ...DRAFTS, id: 'sent', name: '보낸편지함', role: 'sent' }
const wrapper = await mountList(sent, [summary('s1', { mailboxIds: ['sent'] })])
const row = wrapper.get('.message-row')
expect(row.get('.message-row__who').text()).toBe('받는 사람: 이도윤')
expect(row.get('.message-row__main').attributes('href')).toBe('/mail/sent/s1')
})
})
@@ -27,6 +27,8 @@ const showRecipients = computed(() => {
const role = mailbox.value?.role
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
})
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
const empty = computed(() =>
emptyMailboxText({ name: mailbox.value?.name ?? '메일함', role: mailbox.value?.role ?? null }),
)
@@ -70,6 +72,7 @@ function goToInbox() {
:mailbox-id="mailboxId"
:selected="message.id === mailStore.selectedId"
:show-recipients="showRecipients"
:draft="isDrafts"
@toggle-flag="mailStore.toggleFlag"
/>
</ul>
@@ -1,11 +1,15 @@
<script setup lang="ts">
// 분할 보기 목록의 2줄 행 (디자인 원본 Main) — 안 읽음은 뱃지 없이 굵은 글자로만 구분한다
// 임시보관함(draft)의 행은 보낸 사람 자리에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 누르면 쓰기 화면에서 이어 쓴다
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다)
import { computed } from 'vue'
import type { RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { formatMailListTime } from '@/shared/utils/format'
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
interface Props {
message: MessageSummary
@@ -14,6 +18,8 @@ interface Props {
selected?: boolean
/** 보낸편지함처럼 받는 사람을 보여 줄 메일함인지 */
showRecipients?: boolean
/** 임시보관함의 행 — 누르면 쓰기 화면에서 이어 쓴다 */
draft?: boolean
}
interface Emits {
@@ -23,31 +29,42 @@ interface Emits {
const props = withDefaults(defineProps<Props>(), {
selected: false,
showRecipients: false,
draft: false,
})
const emit = defineEmits<Emits>()
/** 임시 저장본의 받는 사람 — 없으면 빈 문자열 */
const draftRecipients = computed(() => (props.draft ? draftRecipientNames(props.message.to) : ''))
const who = computed(() => {
if (props.draft) return draftRecipients.value || DRAFT_NO_RECIPIENTS
if (props.showRecipients) {
const recipients = summarizeRecipients(props.message.to)
return recipients ? `받는 사람: ${recipients}` : '받는 사람 없음'
}
return displayName(props.message.from) || '(보낸 사람 없음)'
})
const subject = computed(() => props.message.subject || '(제목 없음)')
const subject = computed(() => props.message.subject || MAIL_NO_SUBJECT)
const time = computed(() => formatMailListTime(props.message.receivedAt))
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
const unread = computed(() => !props.draft && !props.message.isRead)
const location = computed<RouteLocationRaw>(() =>
props.draft
? { name: 'mail-compose', query: { draft: props.message.id } }
: { name: 'mail-message', params: { mailboxId: props.mailboxId, messageId: props.message.id } },
)
</script>
<template>
<li :class="['message-row', { 'is-selected': selected, 'is-unread': !message.isRead }]">
<li :class="['message-row', { 'is-selected': selected, 'is-unread': unread }]">
<RouterLink
class="message-row__main"
:to="{ name: 'mail-message', params: { mailboxId, messageId: message.id } }"
:to="location"
>
<span class="message-row__top">
<span class="message-row__who">
<span :class="['message-row__who', { 'is-placeholder': draft && draftRecipients === '' }]">
<span
v-if="!message.isRead"
v-if="unread"
class="visually-hidden"
>안 읽음, </span>{{ who }}
</span>
@@ -58,7 +75,7 @@ const time = computed(() => formatMailListTime(props.message.receivedAt))
</span>
<!-- 본문 미리보기는 넣지 않는다 (디자인 원칙) — 제목 뒤에 첨부 클립만 -->
<span class="message-row__bottom">
<span class="message-row__subject">{{ subject }}</span>
<span :class="['message-row__subject', { 'is-placeholder': draft && !message.subject }]">{{ subject }}</span>
<AppIcon
v-if="message.hasAttachment"
name="paperclip"
@@ -166,6 +183,12 @@ const time = computed(() => formatMailListTime(props.message.receivedAt))
color: var(--color-text);
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.message-row__who.is-placeholder,
.message-row__subject.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.message-row.is-unread .message-row__who {
font-weight: 650;
@@ -161,6 +161,52 @@ describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
})
})
describe('MessageReader 첨부 목록 (계약 2절 "MessageAttachment.isInline")', () => {
let pinia: Pinia
beforeEach(() => {
pinia = createPinia()
setActivePinia(pinia)
})
async function mountWith(detail: MessageDetail) {
const store = useMailStore()
store.selected = detail
store.selectedId = detail.id
store.selectedStatus = 'ready'
const router = createTestRouter()
await router.push('/mail/inbox/m1')
await router.isReady()
return mount(MessageReader, { global: { plugins: [pinia, router] } })
}
const REPORT = { id: 'a1', name: '보고서.pdf', type: 'application/pdf', size: 2048 }
const LOGO = { id: 'a2', name: 'signature-1.png', type: 'image/png', size: 1024 }
it('본문 안에 그림으로 보인 첨부(isInline)는 목록 · 개수에서 뺀다', async () => {
const wrapper = await mountWith({
...DETAIL,
hasAttachment: true,
attachments: [
{ ...REPORT, isInline: false },
{ ...LOGO, isInline: true },
],
})
expect(wrapper.get('#reader-attachments-title').text()).toBe('첨부 파일 1개')
expect(wrapper.findAll('.attachments__name').map((name) => name.text())).toEqual(['보고서.pdf'])
})
it('본문 그림뿐이면 첨부 목록이 없다 — 표시 값이 없는(이전 서버) 첨부는 첨부로 본다', async () => {
const inlineOnly = await mountWith({ ...DETAIL, attachments: [{ ...LOGO, isInline: true }] })
expect(inlineOnly.find('#reader-attachments-title').exists()).toBe(false)
inlineOnly.unmount()
const legacy = await mountWith({ ...DETAIL, attachments: [REPORT] })
expect(legacy.findAll('.attachments__name').map((name) => name.text())).toEqual(['보고서.pdf'])
})
})
describe('MessageReader 불러오기 실패', () => {
let pinia: Pinia
@@ -15,6 +15,7 @@ import MessageAttachments from '@/components/mail/MessageAttachments.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { formatMailDateTime, initialOf } from '@/shared/utils/format'
import { displayName, formatAddressList } from '@/shared/utils/mail-address'
import { listedAttachments } from '@/shared/utils/mail-attachment'
import { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import { useMailStore } from '@/stores/mail.store'
@@ -45,6 +46,8 @@ const subject = computed(() => message.value?.subject || '(제목 없음)')
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
/** 서버가 본문을 다 주지 못했을 때의 안내 — 서식을 비웠으면 먼저, 잘렸으면 다음 (계약 2절 "읽기 한도") */
const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.value) : []))
/** 첨부 목록 — 본문 안에 그림으로 보인 것(서명 그림 · 본문 그림, isInline)은 뺀다 */
const attachments = computed(() => (message.value ? listedAttachments(message.value.attachments) : []))
async function onDelete() {
const current = message.value
@@ -241,9 +244,9 @@ function leaveToList() {
/>
<MessageAttachments
v-if="message.attachments.length > 0"
v-if="attachments.length > 0"
:message-id="message.id"
:attachments="message.attachments"
:attachments="attachments"
/>
</div>
</article>
@@ -151,6 +151,37 @@ describe('MessageTable (목록 보기)', () => {
expect(wrapper.findAll('tbody .mail-table__who')[0]?.text()).toBe('박준호')
})
it('임시보관함 — 받는 사람 칸에 받는 사람을 쉼표로, 비었으면 흐린 "(받는 사람 없음)" · "(제목 없음)" — 누르면 쓰기 화면에서 이어 쓴다', async () => {
const drafts: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
const { wrapper, router } = await mountTable({
mailbox: drafts,
messages: [
summary('d1', {
isRead: false,
to: [
{ name: '박준호', email: 'junho.park@arasystem.test' },
{ name: null, email: 'infra@arasystem.test' },
],
}),
summary('d2', { to: [], subject: '' }),
],
})
const rows = wrapper.findAll('tbody tr')
expect(rows[0]?.get('.mail-table__who').text()).toBe('박준호, infra@arasystem.test')
// 임시 저장본은 읽음 여부를 보이지 않는다
expect(rows[0]?.classes()).not.toContain('is-unread')
expect(rows[1]?.get('.mail-table__who').text()).toBe('(받는 사람 없음)')
expect(rows[1]?.get('.mail-table__who').classes()).toContain('is-placeholder')
expect(rows[1]?.get('.mail-table__link').text()).toBe('(제목 없음)')
expect(rows[1]?.get('.mail-table__link').classes()).toContain('is-placeholder')
expect(rows[0]?.get('.mail-table__link').attributes('href')).toBe('/mail/compose?draft=d1')
await rows[1]?.get('.mail-table__who').trigger('click')
await flushPromises()
expect(router.currentRoute.value.fullPath).toBe('/mail/compose?draft=d2')
})
it('서버에 더 있으면 "메일 더 보기" 로 다음 페이지를 불러온다', async () => {
const requests = installMockApi(() =>
ok({ items: [summary('m3')], pageInfo: { hasNext: false, nextCursor: null, total: 15 } }),
+38 -10
View File
@@ -2,8 +2,10 @@
// 목록 보기 — 게시판형 표 (디자인 원본 MainList). 캔버스는 grid 로 그렸지만 구현은 진짜 <table> 이다.
// 칸: 별 32 · 보낸 사람 168 · 제목(나머지) · 날짜 84 · 크기 64 (칸 사이 12) — tokens.css 의 --table-* 값.
// 행 어디를 눌러도 메일이 열리고, 제목은 키보드로 갈 수 있는 링크다. 별 버튼은 따로 동작한다.
// 임시보관함은 받는 사람 칸에 받는 사람(없으면 "(받는 사람 없음)")을 보이고, 행을 누르면 쓰기 화면에서 이어 쓴다
// (디자인 원본 ComposeAssist "임시보관함 목록" — 빈 값은 흐린 글자, 안 읽음 굵기는 쓰지 않는다).
import { computed, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { useRouter, type RouteLocationRaw } from 'vue-router'
import AppIcon from '@/components/AppIcon.vue'
import StateMessage from '@/components/feedback/StateMessage.vue'
@@ -11,10 +13,10 @@ import MailListHeader from '@/components/mail/MailListHeader.vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import MessageListSkeleton from '@/components/mail/MessageListSkeleton.vue'
import { useMailNavigation } from '@/composables/useMailNavigation'
import { OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import { DRAFT_NO_RECIPIENTS, MAIL_NO_SUBJECT, OUTGOING_MAILBOX_ROLES } from '@/shared/constants/mail'
import type { MessageSummary } from '@/shared/types/mail'
import { formatFileSize, formatMailTableDate } from '@/shared/utils/format'
import { displayName, summarizeRecipients } from '@/shared/utils/mail-address'
import { displayName, draftRecipientNames, summarizeRecipients } from '@/shared/utils/mail-address'
import { emptyMailboxText, mailboxIcon } from '@/shared/utils/mailbox'
import { useMailStore } from '@/stores/mail.store'
import { useMailViewStore } from '@/stores/mail-view.store'
@@ -38,6 +40,8 @@ const isOutgoing = computed(() => {
const role = mailbox.value?.role
return role ? OUTGOING_MAILBOX_ROLES.includes(role) : false
})
/** 임시보관함 — 행을 누르면 쓰기 화면에서 이어 쓴다 */
const isDrafts = computed(() => mailbox.value?.role === 'drafts')
const whoHeader = computed(() => (isOutgoing.value ? '받는 사람' : '보낸 사람'))
const caption = computed(() => `${mailbox.value?.name ?? '메일함'} 메일 목록`)
const empty = computed(() =>
@@ -47,16 +51,34 @@ const emptyIcon = computed(() => mailboxIcon({ role: mailbox.value?.role ?? null
const busy = computed(() => mailStore.listStatus === 'loading' || mailStore.isRefreshing)
function whoOf(message: MessageSummary): string {
if (isDrafts.value) return draftRecipientNames(message.to) || DRAFT_NO_RECIPIENTS
if (isOutgoing.value) return summarizeRecipients(message.to) || '받는 사람 없음'
return displayName(message.from) || '(보낸 사람 없음)'
}
/** 임시 저장본의 받는 사람이 없다 — 흐린 글자로 */
function hasNoDraftRecipients(message: MessageSummary): boolean {
return isDrafts.value && draftRecipientNames(message.to) === ''
}
/** 임시 저장본은 읽음 여부를 보이지 않는다 */
function isUnread(message: MessageSummary): boolean {
return !isDrafts.value && !message.isRead
}
/** 행이 여는 곳 — 임시 저장본은 쓰기 화면, 그 밖은 읽기 화면 */
function rowLocation(message: MessageSummary): RouteLocationRaw {
return isDrafts.value
? { name: 'mail-compose', query: { draft: message.id } }
: messageLocation(props.mailboxId, message.id)
}
/** 행 어디를 눌러도 연다 — 링크·별 버튼을 누른 경우와 글자를 고르는 중이면 그대로 둔다 */
function onRowClick(event: MouseEvent, message: MessageSummary) {
const target = event.target instanceof Element ? event.target : null
if (target?.closest('a, button')) return
if (window.getSelection()?.toString()) return
void router.push(messageLocation(props.mailboxId, message.id))
void router.push(rowLocation(message))
}
function retry() {
@@ -167,7 +189,7 @@ onMounted(async () => {
<tr
v-for="message in mailStore.messages"
:key="message.id"
:class="['mail-table__row', { 'is-unread': !message.isRead }]"
:class="['mail-table__row', { 'is-unread': isUnread(message) }]"
@click="onRowClick($event, message)"
>
<td class="mail-table__star">
@@ -186,20 +208,20 @@ onMounted(async () => {
/>
</button>
</td>
<td class="mail-table__who">
<td :class="['mail-table__who', { 'is-placeholder': hasNoDraftRecipients(message) }]">
<span
v-if="!message.isRead"
v-if="isUnread(message)"
class="visually-hidden"
>안 읽음, </span>{{ whoOf(message) }}
</td>
<td class="mail-table__subject">
<div class="mail-table__subject-line">
<RouterLink
class="mail-table__link"
:to="messageLocation(mailboxId, message.id)"
:class="['mail-table__link', { 'is-placeholder': isDrafts && !message.subject }]"
:to="rowLocation(message)"
:data-message-id="message.id"
>
{{ message.subject || '(제목 없음)' }}
{{ message.subject || MAIL_NO_SUBJECT }}
</RouterLink>
<AppIcon
v-if="message.hasAttachment"
@@ -404,6 +426,12 @@ onMounted(async () => {
font-variant-numeric: tabular-nums;
}
/* 비어 있는 값 — "(받는 사람 없음)" · "(제목 없음)" (디자인 원본 ComposeAssist #667085) */
.mail-table__who.is-placeholder,
.mail-table__link.is-placeholder {
color: var(--color-text-muted);
}
/* 안 읽음 — 뱃지 대신 굵기·색으로만 구분 */
.mail-table__row.is-unread .mail-table__who,
.mail-table__row.is-unread .mail-table__link {
@@ -1,14 +1,51 @@
import { describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { shallowRef } from 'vue'
import type { RecipientToken } from '@/shared/utils/mail-address'
import { RECIPIENT_SUGGESTIONS, type RecipientSuggestionsHandle } from '@/composables/useRecipientSuggestions'
import type { RecipientSuggestion } from '@/shared/types/mail'
import { normalizeEmail, type RecipientToken } from '@/shared/utils/mail-address'
import { filterRecipientSuggestions } from '@/shared/utils/recipient-match'
import RecipientField from './RecipientField.vue'
/** v-model 처럼 갱신 이벤트를 받아 다시 props 로 넣는다 — 마지막 값은 models 에서 읽는다 */
const models = new WeakMap<VueWrapper, RecipientToken[]>()
function mountField(initial: RecipientToken[] = []): VueWrapper {
/** 쓰기 화면이 주는 추천 — 받아 둔 목록에서 거르고, 이미 넣은 주소(이 칸의 토큰)는 뺀다 */
function suggestionsOf(pool: RecipientSuggestion[]) {
const items = shallowRef(pool)
const searches: string[] = []
const preloads: number[] = []
let current: () => RecipientToken[] = () => []
const handle: RecipientSuggestionsHandle = {
preload: () => {
preloads.push(preloads.length + 1)
},
matchesFor: (query) =>
filterRecipientSuggestions(
query,
items.value,
new Set(current().map((token) => normalizeEmail(token.address.email))),
),
search: (query) => {
searches.push(query)
},
}
return {
handle,
items,
searches,
preloads,
bind: (tokens: () => RecipientToken[]) => {
current = tokens
},
}
}
function mountField(initial: RecipientToken[] = [], suggestions?: ReturnType<typeof suggestionsOf>): VueWrapper {
const wrapper: VueWrapper = mount(RecipientField, {
attachTo: suggestions ? document.body : undefined,
global: suggestions ? { provide: { [RECIPIENT_SUGGESTIONS as symbol]: suggestions.handle } } : {},
props: {
modelValue: initial,
label: '받는 사람',
@@ -21,6 +58,7 @@ function mountField(initial: RecipientToken[] = []): VueWrapper {
},
})
models.set(wrapper, initial)
suggestions?.bind(() => models.get(wrapper) ?? [])
return wrapper
}
@@ -109,4 +147,246 @@ describe('RecipientField', () => {
await wrapper.get('button[aria-label="a@b.kr 삭제"]').trigger('click')
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['c@d.kr'])
})
describe('자동 완성 (디자인 원본 ComposeAssist)', () => {
const POOL: RecipientSuggestion[] = [
{ name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' },
{ name: '최도윤', email: 'choi@partner.example', source: 'recent' },
{ name: '김도윤', email: 'doyun.kim@arasystem.it.kr', source: 'directory' },
{ name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' },
]
async function typed(wrapper: VueWrapper, text: string) {
const input = wrapper.get('input')
;(input.element as HTMLInputElement).focus()
await input.trigger('focus')
await input.setValue(text)
return input
}
/** 추천 한 줄 — "이름 주소" */
function optionText(option: ReturnType<VueWrapper['find']>): string {
return option
.findAll('.recipient-suggestions__name, .recipient-suggestions__email')
.map((part) => part.text())
.join(' ')
}
function options(wrapper: VueWrapper): string[] {
return wrapper.findAll('[role="option"]').map(optionText)
}
function activeOption(wrapper: VueWrapper): string | null {
const active = wrapper.find('[role="option"][aria-selected="true"]')
return active.exists() ? optionText(active) : null
}
it('한 글자 이상 치면 칸 아래에 사내 · 최근 주고받은 주소 묶음으로 보이고, 맞는 글자는 굵게 · 첫 추천을 골라 둔다', async () => {
const suggestions = suggestionsOf(POOL)
const wrapper = mountField([], suggestions)
const input = await typed(wrapper, 'ㄷㅇ')
expect(input.attributes('role')).toBe('combobox')
expect(input.attributes('aria-expanded')).toBe('true')
expect(input.attributes('aria-controls')).toBe('compose-to-suggestions')
expect(wrapper.get('#compose-to-suggestions').attributes('role')).toBe('listbox')
expect(wrapper.findAll('[role="group"]').map((group) => group.attributes('aria-label'))).toEqual([
'사내',
'최근 주고받은 주소',
])
expect(options(wrapper)).toEqual([
'이도윤 doyun.lee@arasystem.it.kr',
'김도윤 doyun.kim@arasystem.it.kr',
'최도윤 choi@partner.example',
])
expect(wrapper.findAll('#compose-to-option-0 strong').map((part) => part.text())).toEqual(['도윤'])
expect(input.attributes('aria-activedescendant')).toBe('compose-to-option-0')
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기')
expect(input.attributes('aria-describedby')).toBe('compose-to-suggestions-hint')
expect(suggestions.searches).toEqual(['ㄷㅇ'])
wrapper.unmount()
})
it('↑↓ 로 고르고 Enter 로 넣는다 — 폼을 제출하지 않고, 이름 <주소> 토큰이 되고 목록은 닫힌다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
await input.trigger('keydown', { key: 'ArrowDown' })
expect(activeOption(wrapper)).toBe('김도윤 doyun.kim@arasystem.it.kr')
await input.trigger('keydown', { key: 'ArrowUp' })
await input.trigger('keydown', { key: 'ArrowUp' })
expect(activeOption(wrapper)).toBe('최도윤 choi@partner.example')
const enter = new KeyboardEvent('keydown', { key: 'Enter', cancelable: true })
input.element.dispatchEvent(enter)
await wrapper.vm.$nextTick()
expect(enter.defaultPrevented).toBe(true)
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['최도윤 <choi@partner.example>'])
expect((input.element as HTMLInputElement).value).toBe('')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
wrapper.unmount()
})
it('Tab 으로도 넣고 칸에 남아 이어 친다 — 이미 넣은 주소는 다음 추천에서 빠진다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
const tab = new KeyboardEvent('keydown', { key: 'Tab', cancelable: true })
input.element.dispatchEvent(tab)
await wrapper.vm.$nextTick()
expect(tab.defaultPrevented).toBe(true)
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
await input.setValue('도윤')
expect(options(wrapper)).not.toContain('이도윤 doyun.lee@arasystem.it.kr')
wrapper.unmount()
})
it('Esc 는 목록만 닫고, 다시 치면 연다 — 누르면(마우스) 그 추천을 넣는다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '준호')
await input.trigger('keydown', { key: 'Escape' })
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
expect(input.attributes('aria-expanded')).toBe('false')
await input.setValue('준')
const option = wrapper.get('[role="option"]')
const down = new MouseEvent('mousedown', { cancelable: true, bubbles: true })
option.element.dispatchEvent(down)
await option.trigger('click')
expect(down.defaultPrevented).toBe(true)
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['박준호 <junho.park@arasystem.it.kr>'])
wrapper.unmount()
})
it('완전한 주소를 쳤으면 미리 고르지 않는다 — Enter 는 친 주소를 그대로, 화살표로 고른 뒤에만 추천이 들어간다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, 'doyun.lee@partner.example')
expect(options(wrapper)).toEqual(['이도윤 doyun.lee@arasystem.it.kr'])
expect(activeOption(wrapper)).toBeNull()
expect(input.attributes('aria-activedescendant')).toBeUndefined()
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('Enter 는 친 주소를 그대로 넣습니다')
await input.trigger('keydown', { key: 'Enter' })
expect(tokensOf(wrapper).map((token) => token.label)).toEqual(['doyun.lee@partner.example'])
await input.setValue('doyun.lee@partner.example')
await input.trigger('keydown', { key: 'ArrowDown' })
expect(wrapper.get('#compose-to-suggestions-hint').text()).toBe('↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기')
await input.trigger('keydown', { key: 'Enter' })
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual([
'doyun.lee@partner.example',
'doyun.lee@arasystem.it.kr',
])
wrapper.unmount()
})
it('한글 조합 중의 Enter 는 추천도 넣지 않는다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
await input.trigger('keydown', { key: 'Enter', isComposing: true })
expect(tokensOf(wrapper)).toEqual([])
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
wrapper.unmount()
})
it('Safari · macOS 처럼 조합을 끝내는 Enter 가 isComposing 없이 keyCode 229 로 와도 넣지 않는다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
const enter = new KeyboardEvent('keydown', { key: 'Enter', keyCode: 229, cancelable: true })
input.element.dispatchEvent(enter)
await wrapper.vm.$nextTick()
expect(enter.defaultPrevented).toBe(false)
expect(tokensOf(wrapper)).toEqual([])
wrapper.unmount()
})
it('칸에 초점이 오면 미리 받기를 부른다 — 실패했던 미리 받기를 다시 해 본다 (받았으면 추천 쪽이 아무것도 하지 않는다)', async () => {
const suggestions = suggestionsOf(POOL)
const wrapper = mountField([], suggestions)
expect(suggestions.preloads).toHaveLength(0)
await wrapper.get('input').trigger('focus')
expect(suggestions.preloads).toHaveLength(1)
wrapper.unmount()
})
it('Shift+Tab 은 추천을 넣지 않는다 — 원래대로 앞 칸으로 (막지 않는다)', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, '도윤')
const back = new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, cancelable: true })
input.element.dispatchEvent(back)
await wrapper.vm.$nextTick()
expect(back.defaultPrevented).toBe(false)
expect(tokensOf(wrapper)).toEqual([])
wrapper.unmount()
})
it('한글 조합 중인 글자로도 찾는다 — "김"(조합 중) · "ㄷㅇ"(마지막 자모가 조합 중). 토큰 확정은 조합이 끝난 글자로', async () => {
const suggestions = suggestionsOf([
...POOL,
{ name: '김아라', email: 'ara.kim@arasystem.it.kr', source: 'directory' },
])
const wrapper = mountField([], suggestions)
const input = wrapper.get('input')
const element = input.element as HTMLInputElement
element.focus()
await input.trigger('focus')
// "김" 을 조합하는 중 — v-model 은 아직 비어 있지만 추천은 보인다
await input.trigger('compositionstart')
element.value = '김'
await input.trigger('compositionupdate')
await input.trigger('input')
expect(options(wrapper)).toEqual(['김도윤 doyun.kim@arasystem.it.kr', '김아라 ara.kim@arasystem.it.kr'])
expect(suggestions.searches.at(-1)).toBe('김')
await input.trigger('compositionend')
// "ㄷ" 을 확정하고 "ㅇ" 을 조합하는 중 — 초성 "ㄷㅇ" 으로 찾는다
element.value = ''
await input.trigger('input')
await input.trigger('compositionstart')
element.value = 'ㄷ'
await input.trigger('input')
await input.trigger('compositionend')
await input.trigger('compositionstart')
element.value = 'ㄷㅇ'
await input.trigger('input')
expect(options(wrapper)).toEqual([
'이도윤 doyun.lee@arasystem.it.kr',
'김도윤 doyun.kim@arasystem.it.kr',
'최도윤 choi@partner.example',
])
// 조합 중의 Enter 는 넣지 않는다 — 조합이 끝난 뒤의 Enter 가 고른 추천을 넣는다
await input.trigger('keydown', { key: 'Enter', isComposing: true })
expect(tokensOf(wrapper)).toEqual([])
await input.trigger('compositionend')
await input.trigger('keydown', { key: 'Enter' })
expect(tokensOf(wrapper).map((token) => token.address.email)).toEqual(['doyun.lee@arasystem.it.kr'])
expect(element.value).toBe('')
wrapper.unmount()
})
it('칸을 벗어나면 목록을 닫고 친 글자를 확정한다 — 맞는 추천이 없으면 목록을 열지 않는다', async () => {
const wrapper = mountField([], suggestionsOf(POOL))
const input = await typed(wrapper, 'zzz')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
await input.setValue('준호')
expect(wrapper.find('[role="listbox"]').exists()).toBe(true)
await input.trigger('blur')
expect(wrapper.find('[role="listbox"]').exists()).toBe(false)
wrapper.unmount()
})
})
})
+164 -9
View File
@@ -1,18 +1,39 @@
<script setup lang="ts">
// 받는 사람 · 참조 · 숨은참조 입력 — Enter · 쉼표 · 입력칸을 벗어날 때 토큰으로 확정한다 (디자인 원본 Compose)
// 형식이 틀린 주소는 빨간 물결 밑줄로 바로 표시한다. 오류 문구(MAIL_002)는 쓰기 화면이 사전에서 가져와 보인다.
// 자동 완성 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — 쓰기 화면이 추천(RECIPIENT_SUGGESTIONS)을 주면:
// - 한 글자 이상 치면 칸 바로 아래에 추천 목록(최대 8) — "사내" · "최근 주고받은 주소" 두 묶음, 맞는 글자는 굵게
// - ↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기 · 눌러 넣기. 처음에는 첫 추천을 골라 둔다 —
// 친 글자가 완전한 메일 주소면 고르지 않는다: Enter 는 친 주소를 그대로 넣고, 화살표로 고른 뒤에만 추천이 들어간다.
// Shift+Tab 은 추천을 넣지 않는다 (원래대로 앞 칸으로 — 칸을 벗어나며 친 글자를 확정한다)
// - 추천은 입력칸에 보이는 글 그대로 찾는다 — 한글 조합 중인 글자도 ("김" · "ㄷㅇ"). v-model 은 조합이 끝나야 바뀌므로
// input · compositionupdate 의 값을 따로 둔다(query). 주소 토큰 확정은 v-model(draft) 그대로다
// - 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다 (isImeComposing — Safari 의 keyCode 229 포함)
// 목록은 RecipientSuggestionList 가 그린다 — 고르기 · 넣기 키는 이 입력칸이 받는다(초점은 늘 입력칸).
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
// 사용 예: <RecipientField v-model="to" label="받는 사람" input-id="compose-to" error-id="compose-recipient-error" />
import { computed, ref } from 'vue'
import { computed, inject, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import RecipientSuggestionList from '@/components/mail/RecipientSuggestionList.vue'
import {
suggestionHintId,
suggestionListId,
suggestionOptionId,
} from '@/components/mail/recipient-suggestion-ids'
import { RECIPIENT_SUGGESTIONS } from '@/composables/useRecipientSuggestions'
import {
hasOpenRecipientGroup,
isValidEmail,
mergeRecipientTokens,
parseRecipient,
recipientTokenKey,
splitRecipientInput,
tokenFromAddress,
type RecipientToken,
} from '@/shared/utils/mail-address'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import type { RecipientMatch } from '@/shared/utils/recipient-match'
interface Props {
modelValue: RecipientToken[]
@@ -36,27 +57,103 @@ const props = withDefaults(defineProps<Props>(), {
const emit = defineEmits<Emits>()
/** 아직 토큰이 되지 않은 입력 */
/** 추천 — 쓰기 화면이 주지 않았으면 자동 완성 없이 쓴다 */
const suggestions = inject(RECIPIENT_SUGGESTIONS, null)
const HINT_DEFAULT = '↑↓ 고르기 · Enter · Tab 넣기 · Esc 닫기'
const HINT_TYPED_ADDRESS = 'Enter 는 친 주소를 그대로 넣습니다'
/** 아직 토큰이 되지 않은 입력 — v-model (한글 조합이 끝나야 바뀐다) */
const draft = ref<string>('')
/** 추천 검색어 — 입력칸에 보이는 글 그대로(한글 조합 중인 글자 포함). 조합이 끝나면 draft 와 같아진다 */
const query = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
/** 입력칸에 초점이 있다 — 그동안만 추천 목록을 연다 */
const focused = ref<boolean>(false)
/** Esc 로 닫았다 — 다시 치면 연다 */
const dismissed = ref<boolean>(false)
/** 고른 추천 — -1 이면 고르지 않았다 (Enter 는 친 글자를 넣는다) */
const activeIndex = ref<number>(-1)
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
const hasDraft = computed(() => draft.value.trim() !== '')
const hasDraft = computed(() => draft.value.trim() !== '' || query.value.trim() !== '')
const listId = computed(() => suggestionListId(props.inputId))
const hintId = computed(() => suggestionHintId(props.inputId))
const matches = computed<RecipientMatch[]>(() => (suggestions ? suggestions.matchesFor(query.value) : []))
const listOpen = computed(
() =>
focused.value && !props.disabled && !dismissed.value && query.value.trim() !== '' && matches.value.length > 0,
)
/** 친 글자가 이미 완전한 메일 주소다 — 추천을 미리 고르지 않는다 */
const typedFullAddress = computed(() => isValidEmail(query.value))
const activeOptionId = computed(() =>
listOpen.value && activeIndex.value >= 0 ? suggestionOptionId(props.inputId, activeIndex.value) : undefined,
)
const hint = computed(() => (typedFullAddress.value && activeIndex.value < 0 ? HINT_TYPED_ADDRESS : HINT_DEFAULT))
const describedBy = computed(() => {
const ids = [hasInvalid.value && props.errorId ? props.errorId : '', listOpen.value ? hintId.value : '']
return ids.filter((id) => id !== '').join(' ') || undefined
})
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
function commit(text: string = draft.value) {
const tokens = splitRecipientInput(text)
.map(parseRecipient)
.filter((token): token is RecipientToken => token !== null)
draft.value = ''
clearDraft()
if (tokens.length === 0) return
emit('update:modelValue', mergeRecipientTokens(props.modelValue, tokens))
}
/** 추천을 넣는다 — "이름 <주소>" 토큰으로. 초점은 입력칸에 남아 다음 주소를 이어 친다 */
function pick(match: RecipientMatch) {
const { name, email } = match.suggestion
clearDraft()
emit('update:modelValue', mergeRecipientTokens(props.modelValue, [tokenFromAddress({ name, email })]))
}
/** 화살표로 고른다 — 끝에서 처음으로 돈다 */
function move(step: 1 | -1) {
const count = matches.value.length
if (count === 0) return
if (activeIndex.value < 0) {
activeIndex.value = step === 1 ? 0 : count - 1
return
}
activeIndex.value = (activeIndex.value + step + count) % count
}
/** 추천 목록의 키 — 처리했으면 true */
function onListKeydown(event: KeyboardEvent): boolean {
if (!listOpen.value) return false
if (event.key === 'ArrowDown' || event.key === 'ArrowUp') {
event.preventDefault()
move(event.key === 'ArrowDown' ? 1 : -1)
return true
}
if (event.key === 'Escape') {
event.preventDefault()
dismissed.value = true
return true
}
const active = matches.value[activeIndex.value]
// Shift+Tab 은 넣지 않는다 — 원래대로 앞 칸으로 간다
if ((event.key === 'Enter' || (event.key === 'Tab' && !event.shiftKey)) && active) {
// Enter 가 폼을 제출(메일 발송)하지 않고, Tab 이 칸을 벗어나지 않게 — 넣은 뒤 이어 친다
event.preventDefault()
pick(active)
return true
}
return false
}
function onKeydown(event: KeyboardEvent) {
// 한글 조합 중의 Enter 는 글자 확정이다 — 토큰으로 만들지 않는다
if (event.isComposing) return
// 한글 조합 중의 Enter · Tab 은 글자 확정이다 — 토큰으로도 추천으로도 넣지 않는다
if (isImeComposing(event)) return
if (onListKeydown(event)) return
const isSeparator =
(event.key === ',' || event.key === ';') && !hasOpenRecipientGroup(draft.value)
@@ -79,6 +176,22 @@ function onPaste(event: ClipboardEvent) {
commit(`${draft.value}${text}`)
}
/** 입력칸에 보이는 글 — 한글 조합 중인 글자도 (v-model 은 조합 중의 input 을 건너뛴다) */
function onTextInput(event: Event) {
query.value = (event.target as HTMLInputElement).value
}
function onFocus() {
focused.value = true
// 미리 받기가 실패했으면 다시 받아 본다 (받았거나 받는 중이면 아무것도 하지 않는다)
suggestions?.preload()
}
function onBlur() {
focused.value = false
commit()
}
function remove(token: RecipientToken) {
const key = recipientTokenKey(token)
emit(
@@ -92,11 +205,33 @@ function focus() {
inputRef.value?.focus()
}
/** 아직 토큰이 되지 않은 입력을 버린다 — 쓰기 화면이 양식을 새로 채울 때 */
/** 아직 토큰이 되지 않은 입력을 버린다 — 확정 · 추천을 넣은 뒤, 쓰기 화면이 양식을 새로 채울 때 */
function clearDraft() {
draft.value = ''
query.value = ''
}
// v-model 이 바뀌면(조합이 끝남 · 확정 · 비움) 검색어도 그 값으로
watch(draft, (text) => {
query.value = text
})
// 친 글자가 바뀌면 — 닫았던 목록을 다시 열고, 첫 추천을 골라 둔다(완전한 주소면 고르지 않는다). 모자라면 서버에 묻는다
watch(query, (text) => {
dismissed.value = false
activeIndex.value = isValidEmail(text) ? -1 : 0
suggestions?.search(text)
})
// 추천이 늘거나 줄면(서버 결과 · 이미 넣은 주소) 고른 자리를 목록 안에 둔다
watch(
() => matches.value.length,
(count) => {
if (activeIndex.value >= count) activeIndex.value = count - 1
if (activeIndex.value < 0 && count > 0 && !typedFullAddress.value) activeIndex.value = 0
},
)
defineExpose({ commit, focus, clearDraft, hasDraft })
</script>
@@ -141,19 +276,39 @@ defineExpose({ commit, focus, clearDraft, hasDraft })
class="recipient-field__input"
:placeholder="modelValue.length === 0 ? placeholder : ''"
:disabled="disabled"
:role="suggestions ? 'combobox' : undefined"
:aria-autocomplete="suggestions ? 'list' : undefined"
:aria-expanded="suggestions ? listOpen : undefined"
:aria-controls="listOpen ? listId : undefined"
:aria-activedescendant="activeOptionId"
:aria-invalid="hasInvalid"
:aria-describedby="hasInvalid && errorId ? errorId : undefined"
:aria-describedby="describedBy"
@input="onTextInput"
@compositionupdate="onTextInput"
@keydown="onKeydown"
@blur="commit()"
@focus="onFocus"
@blur="onBlur"
@paste="onPaste"
>
</div>
<slot name="trailing" />
<RecipientSuggestionList
v-if="listOpen"
:input-id="inputId"
:label="label"
:matches="matches"
:active-index="activeIndex"
:hint="hint"
@activate="activeIndex = $event"
@pick="pick"
/>
</div>
</template>
<style scoped>
.recipient-field {
position: relative;
display: flex;
flex-wrap: wrap;
align-items: center;
@@ -0,0 +1,217 @@
<script setup lang="ts">
// 받는 사람 추천 목록 (디자인 원본 ComposeAssist · README "2단계 › 받는 사람 자동 완성") — RecipientField 가 칸 바로 아래에 연다
// - "사내" · "최근 주고받은 주소" 두 묶음, 줄마다 이름(맞는 글자 굵게) + 주소, 고른 줄은 바탕색. 번호는 화살표 순서다
// - 고르기 · 넣기 키는 입력칸이 받는다(초점은 늘 입력칸) — 이 목록은 그리기와 마우스(올리면 고르기 · 누르면 넣기)만 한다.
// 누르는 동안 입력칸의 초점을 지킨다(mousedown)
// 고정 id — 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint (recipient-suggestion-ids.ts)
import { computed } from 'vue'
import {
suggestionHintId,
suggestionListId,
suggestionOptionId,
} from '@/components/mail/recipient-suggestion-ids'
import { highlightParts, type HighlightPart, type RecipientMatch } from '@/shared/utils/recipient-match'
interface Props {
/** 입력칸 id — 목록 · 추천 · 안내 id 의 앞부분 */
inputId: string
/** 칸 이름 — 목록의 이름("받는 사람 추천") */
label: string
matches: RecipientMatch[]
/** 고른 추천 — -1 이면 고르지 않았다 */
activeIndex: number
/** 목록 아래 안내 */
hint: string
}
interface Emits {
/** 누른 추천을 넣는다 */
(e: 'pick', match: RecipientMatch): void
/** 마우스를 올린 줄을 고른다 */
(e: 'activate', index: number): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
/** 추천 묶음 이름 (디자인 원본 ComposeAssist) */
const GROUP_LABELS = { directory: '사내', recent: '최근 주고받은 주소' } as const
interface OptionView {
index: number
id: string
match: RecipientMatch
/** 첫 줄 — 이름(없으면 주소) */
primary: HighlightPart[]
/** 둘째 줄 — 주소 (이름이 없으면 없다) */
secondary: HighlightPart[] | null
}
function optionView(match: RecipientMatch, index: number): OptionView {
const { name, email } = match.suggestion
return {
index,
id: suggestionOptionId(props.inputId, index),
match,
primary: name ? highlightParts(name, match.name) : highlightParts(email, match.email),
secondary: name ? highlightParts(email, match.email) : null,
}
}
/** 묶음별 추천 — 사내 → 최근 주고받은 주소, 번호는 화살표 순서 */
const groups = computed(() => {
const options = props.matches.map(optionView)
return (['directory', 'recent'] as const)
.map((source) => ({
source,
label: GROUP_LABELS[source],
options: options.filter((option) => option.match.suggestion.source === source),
}))
.filter((group) => group.options.length > 0)
})
</script>
<template>
<!-- 칸 바로 아래에 겹쳐 연다 (디자인 원본 ComposeAssist). 누르는 동안 입력칸의 초점을 지킨다(mousedown) -->
<div class="recipient-suggestions">
<div
:id="suggestionListId(inputId)"
role="listbox"
:aria-label="`${label} 추천`"
class="recipient-suggestions__list"
>
<div
v-for="group in groups"
:key="group.source"
role="group"
:aria-label="group.label"
class="recipient-suggestions__group"
>
<span
aria-hidden="true"
class="recipient-suggestions__group-label"
>{{ group.label }}</span>
<div
v-for="option in group.options"
:id="option.id"
:key="option.id"
role="option"
:aria-selected="option.index === activeIndex"
:class="['recipient-suggestions__option', { 'is-active': option.index === activeIndex }]"
@mousedown.prevent
@mousemove="emit('activate', option.index)"
@click="emit('pick', option.match)"
>
<span class="recipient-suggestions__name">
<template
v-for="(part, partIndex) in option.primary"
:key="partIndex"
>
<strong v-if="part.strong">{{ part.text }}</strong>
<template v-else>{{ part.text }}</template>
</template>
</span>
<span
v-if="option.secondary"
class="recipient-suggestions__email"
>
<template
v-for="(part, partIndex) in option.secondary"
:key="partIndex"
>
<strong v-if="part.strong">{{ part.text }}</strong>
<template v-else>{{ part.text }}</template>
</template>
</span>
</div>
</div>
</div>
<p
:id="suggestionHintId(inputId)"
class="recipient-suggestions__hint"
>
{{ hint }}
</p>
</div>
</template>
<style scoped>
/*
* 추천 목록 (디자인 원본 ComposeAssist) — 칸(.recipient-field, position: relative) 바로 아래에 겹쳐 연다. 편집기 툴바(z-index 2) 위에 온다.
* 줄 높이 40 · 이름(맞는 글자 굵게) + 주소(12px #4A5363) · 고른 줄 #E3F2F3 · 묶음 이름 12px #667085 굵게
*/
.recipient-suggestions {
position: absolute;
top: calc(100% + var(--space-4));
left: var(--content-inline);
right: var(--content-toolbar-start);
z-index: 4;
max-width: 36rem;
display: flex;
flex-direction: column;
padding: var(--space-6);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
box-shadow: var(--shadow-toast);
}
.recipient-suggestions__list,
.recipient-suggestions__group {
display: flex;
flex-direction: column;
}
.recipient-suggestions__group + .recipient-suggestions__group {
margin-top: var(--space-4);
padding-top: var(--space-4);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.recipient-suggestions__group-label {
padding: var(--space-4) var(--space-8) var(--space-2);
font-size: var(--font-size-small);
font-weight: 600;
color: var(--color-text-muted);
}
.recipient-suggestions__option {
display: flex;
align-items: baseline;
gap: var(--space-8);
min-height: var(--size-row-min);
padding: var(--space-9) var(--space-8);
border-radius: var(--radius-sm);
color: var(--color-text);
cursor: pointer;
}
.recipient-suggestions__option.is-active {
background: var(--color-brand-subtle);
}
.recipient-suggestions__name {
flex: 0 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.recipient-suggestions__email {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: var(--font-size-small);
color: var(--color-text-secondary);
}
.recipient-suggestions__hint {
margin: var(--space-4) var(--space-8) var(--space-2);
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
</style>
@@ -9,6 +9,7 @@ import AppIcon from '@/components/AppIcon.vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
import type { LinkFormValue } from '@/components/mail/editor/editor-menu'
import { getErrorMessage } from '@/composables/useApi'
import { isImeComposing } from '@/shared/utils/keyboard-shortcut'
import { normalizeLinkHref } from '@/shared/utils/link-href'
interface Props {
@@ -58,8 +59,8 @@ function submit() {
}
function onEnter(event: KeyboardEvent) {
// 한글 조합 중의 Enter 는 글자를 확정할 뿐이다
if (event.isComposing) return
// 한글 조합 중의 Enter 는 글자를 확정할 뿐이다 (Safari · macOS 의 keyCode 229 포함)
if (isImeComposing(event)) return
event.preventDefault()
submit()
}
@@ -160,6 +160,22 @@ describe('MailEditor (서식 편집기)', () => {
expect(view.vm.getContentForSend()).toBeNull()
expect(editor.state.doc).toBe(before)
})
it('임시 저장도 같은 한도로 잰다 — 넘으면 인용을 줄인 모양을 싣되 편집기는 그대로, 내 글만으로 넘으면 null', () => {
const view = mountEditor({ initialHtml: longQuoteDraft(), fitToLimits: true })
const editor = editorOf(view.element)
const mine = '내가 쓴 긴 답장입니다. '.repeat(600)
editor.commands.insertContentAt(1, mine)
const before = editor.state.doc
const snapshot = view.vm.captureForSave()
expect(snapshot?.content.text.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
expect(snapshot?.content.text.startsWith(mine.trimEnd())).toBe(true)
expect(editor.state.doc).toBe(before)
editor.commands.setContent(`<p>${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)}</p>`)
expect(view.vm.captureForSave()).toBeNull()
}, LONG_DRAFT_TIMEOUT_MS)
})
it('본문 오류 안내(describedBy)를 주면 본문이 그 안내를 가리키고 잘못된 입력으로 읽힌다', async () => {
@@ -313,4 +329,54 @@ describe('MailEditor (서식 편집기)', () => {
expect(view.get('.mail-editor__drop').text()).toBe('놓으면 첨부 파일로 올립니다')
})
describe('임시 저장 · 서명 그림 (2단계)', () => {
const SIGNATURE_IMAGE = '<img src="/api/mail-signature/images/3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d" width="144" alt="">'
it('저장한 뒤에는 저장한 본문과 견준다 — 저장하는 동안 더 쓴 글은 바꿈으로 남는다', () => {
const view = mountEditor()
const editor = editorOf(view.element)
editor.commands.insertContent('첫 줄')
const snapshot = view.vm.captureForSave()
if (!snapshot) throw new Error('저장할 본문이 없다')
expect(snapshot.content.text).toBe('첫 줄')
// 저장하는 동안 더 썼다
editor.commands.insertContent(' 더')
view.vm.markSaved(snapshot.savePoint)
expect(view.vm.isChanged()).toBe(true)
// 더 쓴 글을 지워 저장한 모양으로 돌아오면 바꾸지 않은 것이다
const end = editor.state.doc.content.size - 1
editor.commands.deleteRange({ from: end - ' 더'.length, to: end })
expect(editor.getText()).toBe('첫 줄')
expect(view.vm.isChanged()).toBe(false)
})
it('서명 그림은 본문에 남고, 글자 없이 그림만 있어도 서식 본문으로 보낸다 (텍스트 대체 본문은 빈 글)', () => {
const view = mountEditor({ initialHtml: `<p>${SIGNATURE_IMAGE}</p>` })
const { text, html } = view.vm.getContent()
expect(text).toBe('')
expect(html).toContain(SIGNATURE_IMAGE)
})
it('붙여 넣은 HTML 의 다른 그림은 빠진다 — 서명 그림 주소만 받는다', () => {
const view = mountEditor()
const dom = view.get('.ProseMirror').element
dom.dispatchEvent(
pasteEvent({
text: '글',
html: `<p>글<img src="https://tracker.example/p.gif"><img src="data:image/png;base64,AAAA">${SIGNATURE_IMAGE}</p>`,
}),
)
const { html } = view.vm.getContent()
expect(html).toContain(SIGNATURE_IMAGE)
expect(html).not.toContain('tracker.example')
expect(html).not.toContain('data:image')
})
})
})
@@ -1,8 +1,11 @@
<script setup lang="ts">
// 메일 본문 서식 편집기 — 툴바 + 편집 영역 (디자인 원본 Compose · ComposeEditor, README "본문 서식 편집기")
// TipTap(ProseMirror)은 이 폴더(components/mail/editor/) 안에서만 쓴다 — 쓰기 화면이 지연 로딩한다(ComposePage).
// - 처음 내용(initialHtml)은 편집기 스키마로 옮긴다. 스키마에 없는 요소(이미지·스크립트 등)는 빠지고,
// - 처음 내용(initialHtml)은 편집기 스키마로 옮긴다. 스키마에 없는 요소(서명 그림이 아닌 이미지·스크립트 등)는 빠지고,
// 서식 값은 서버가 남기는 형식만 받는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
// 그림은 서명 그림만 받는다 (signature-image.ts — 쓰기 화면이 넣어 둔 서명 · 임시 저장본).
// - 임시 저장: captureForSave 로 저장할 본문과 그 순간의 기준점을 받고, 저장이 끝나면 markSaved 로 돌려준다 —
// 그 뒤의 isChanged 는 저장한 본문과 견준다 (쓰기 화면의 나가기 확인 · 자동 저장).
// - 답장·전달 초안은 한도(텍스트 100,000 · 서식 500,000)에서 내 글 몫(5%)을 남기고 원문 인용을 줄인다(fit-draft.ts).
// 맞춘 내용으로 편집기를 만들므로 실행 취소 기록은 맞춘 내용에서 시작한다.
// 보내기 직전에 다시 재어(getContentForSend) 넘으면 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않는다.
@@ -24,8 +27,13 @@ import AppIcon from '@/components/AppIcon.vue'
import EditorToolbar from '@/components/mail/editor/EditorToolbar.vue'
import { INITIAL_DRAFT_LIMITS, fitDraftDoc, fitsLimits } from '@/components/mail/editor/fit-draft'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import type { MailEditorContent } from '@/components/mail/editor/mail-editor-handle'
import type {
EditorSavePoint,
MailEditorContent,
MailEditorSnapshot,
} from '@/components/mail/editor/mail-editor-handle'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import { hasSignatureImage } from '@/components/mail/editor/signature-image'
import { DROP_TO_ATTACH_LABEL, MAIL_EDITOR_BODY_ID } from '@/shared/constants/mail-editor'
import { pastedFiles, pastedImageName, renameFile } from '@/shared/utils/editor-files'
@@ -69,6 +77,7 @@ let mounted = false
* 편집기를 다 연 때의 문서(답장 · 전달 초안은 맞춘 뒤) — 쓰기 화면이 "본문을 바꿨는가"를 이것과 비교한다 (나가기 전 확인).
* settled: 같은 문서에 편집기가 스스로 끝 빈 줄을 붙인 모양 — 끝이 인용처럼 문단이 아니면 첫 거래(초점 옮기기 등) 때
* 붙인다(StarterKit 의 TrailingNode). 사용자가 쓴 것이 아니므로 "바꿈"이 아니다.
* 임시 저장이 끝나면 저장한 문서로 바꾼다 (markSaved — 이미 거래를 거친 문서라 settled 도 같다).
*/
let opened: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
@@ -139,8 +148,9 @@ const editor = markRaw(
/** 문서의 보낼 모양 — 서식 본문과 같은 내용의 텍스트 */
function contentOf(doc: ProseMirrorNode): MailEditorContent {
const text = docToText(doc)
// 보이는 내용이 없으면 텍스트 메일로 — 빈 문단만 남은 서식 본문을 보내지 않는다
if (text.trim() === '') return { text: '', html: null }
// 보이는 내용이 없으면 텍스트 메일로 — 빈 문단만 남은 서식 본문을 보내지 않는다.
// 글자 없이 서명 그림만 있으면 서식 본문으로 보낸다 (텍스트 대체 본문에는 그림이 없다)
if (text.trim() === '' && !hasSignatureImage(doc)) return { text: '', html: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)) }
}
@@ -182,13 +192,35 @@ function getContentForSend(): MailEditorContent | null {
return content
}
/** 연 뒤에 본문을 바꿨는가 — 지웠다 다시 써 처음과 같아졌으면 바꾸지 않은 것이다 */
/** 연 뒤에(임시 저장했으면 저장한 뒤에) 본문을 바꿨는가 — 지웠다 다시 써 같아졌으면 바꾸지 않은 것이다 */
function isChanged(): boolean {
if (!opened) return false
const { doc } = editor.state
return !doc.eq(opened.doc) && !doc.eq(opened.settled)
}
/**
* 임시 저장할 본문과 그 순간의 문서 — 저장하는 동안 더 쓴 글은 markSaved 뒤에도 "바꿈"으로 남는다.
* 보내기(getContentForSend)와 같은 한도로 잰다. 넘으면 인용을 줄인 모양을 싣되 편집기는 바꾸지 않는다 — 쓰는 중에 화면이 바뀌지 않게.
* 인용을 다 줄여도 넘으면 null
*/
function captureForSave(): MailEditorSnapshot | null {
const { doc } = editor.state
const savePoint = doc as unknown as EditorSavePoint
const current = contentOf(doc)
if (contentFits(current)) return { content: current, savePoint }
const fitted = fitDraftDoc(doc, editor.schema)
if (fitted === doc) return null
const content = contentOf(fitted)
return contentFits(content) ? { content, savePoint } : null
}
/** 임시 저장이 끝났다 — 그때의 문서를 기준으로 삼는다 */
function markSaved(savePoint: EditorSavePoint) {
const doc = savePoint as unknown as ProseMirrorNode
opened = { doc, settled: doc }
}
/**
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
@@ -227,7 +259,7 @@ watch(
},
)
defineExpose({ getContent, getContentForSend, focus, isChanged })
defineExpose({ getContent, getContentForSend, focus, isChanged, captureForSave, markSaved })
</script>
<template>
@@ -390,6 +422,18 @@ defineExpose({ getContent, getContentForSend, focus, isChanged })
color: var(--color-brand);
}
/* 서명 그림 — 받는 쪽과 같은 폭(width 속성)으로, 칸보다 넓으면 줄여 보인다. 고르면 브랜드색 고리 (디자인 원본 Settings) */
.mail-editor__body :deep(.ProseMirror img) {
max-width: 100%;
height: auto;
vertical-align: bottom;
}
.mail-editor__body :deep(.ProseMirror img.ProseMirror-selectednode) {
outline: var(--focus-ring-width) solid var(--color-brand);
outline-offset: var(--focus-ring-offset);
}
/* 빈 본문 안내 — 커서는 문구 앞에 선다 */
.mail-editor__body :deep(.ProseMirror p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
@@ -9,6 +9,20 @@ export interface MailEditorContent {
html: string | null
}
/**
* 저장한 순간의 본문 — 편집기 밖에서는 열어 보지 않는 값이다 (TipTap 문서를 쓰기 화면이 모르게 감싼다).
* 임시 저장이 끝나면 markSaved 로 돌려줘 "바꾸지 않은" 기준으로 삼는다.
*/
export interface EditorSavePoint {
readonly __editorSavePoint: true
}
/** 임시 저장할 본문 — 보낼 모양(한도에 맞추지 않은 지금 본문)과 그 순간의 기준점 */
export interface MailEditorSnapshot {
content: MailEditorContent
savePoint: EditorSavePoint
}
export interface MailEditorHandle {
/** 지금 본문 — 보낼 모양 그대로 */
getContent: () => MailEditorContent
@@ -21,9 +35,18 @@ export interface MailEditorHandle {
focus: (position?: 'start' | 'end') => void
/**
* 편집기를 연 뒤(답장 · 전달 초안을 맞춘 뒤) 사용자가 본문을 바꿨는가 — 쓰기 화면의 나가기 전 확인이 쓴다.
* 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false
* 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false.
* 임시 저장한 뒤에는 저장한 본문(markSaved)과 견준다
*/
isChanged: () => boolean
/**
* 임시 저장할 본문 — 저장이 끝나면 그 기준점을 markSaved 로 돌려준다 (저장하는 동안 바뀐 글은 "바꿈"으로 남는다).
* 보내기와 같은 한도로 잰다: 넘으면 원문 인용을 줄인 모양을 싣는다(편집기는 그대로 — 보낼 때 맞춘다).
* 인용을 다 줄여도 넘으면(내 글만으로 넘는다) null — 저장하지 않는다 (서버도 MAIL_007 로 거절한다)
*/
captureForSave: () => MailEditorSnapshot | null
/** 저장이 끝났다 — 그때의 본문을 "바꾸지 않은" 기준으로 삼는다 (isChanged 가 이것과 견준다) */
markSaved: (savePoint: EditorSavePoint) => void
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
@@ -1,7 +1,8 @@
// 서식 편집기(TipTap) 확장 구성 — 디자인 README "본문 서식 편집기 (TipTap)" · 계약 2절 "서식 본문"
// 편집기가 만드는 것 = 서버 정화 허용 목록(sanitize-outgoing-html.ts)에 있는 것만 둔다:
// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크.
// 제목 스타일·코드·이미지·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크, 서명 그림.
// 제목 스타일·코드·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
// 그림은 서명 그림(/api/mail-signature/images/{id})만 받는다 (signature-image.ts — 쓰기 화면이 넣어 둔 서명 · 임시 저장본).
// 붙여 넣은 글 · 원문 인용의 서식 값은 서버 형식에 맞는 것만 읽는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
import { Extension, type AnyExtension } from '@tiptap/core'
import { TableKit } from '@tiptap/extension-table'
@@ -22,6 +23,7 @@ import {
mailOrderedList,
starterKitPart,
} from '@/components/mail/editor/mail-format-extensions'
import { SignatureImage } from '@/components/mail/editor/signature-image'
import {
EDITOR_INDENT_STEP_PX,
EDITOR_MAX_INDENT,
@@ -228,6 +230,11 @@ const LINK_OPTIONS = {
HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' },
}
/** 링크 확장 — 쓰기 본문과 서명 칸이 같은 규칙을 쓴다 (주소만 읽고, 늘 새 창으로) */
export function createMailLinkExtension(): AnyExtension {
return mailLink(starterKitPart(StarterKit.configure({ ...STARTER_KIT_OPTIONS, link: LINK_OPTIONS }), 'link'))
}
/** 편집기 확장 목록 — 화면의 편집기와 초안 맞춤(fit-draft)·테스트가 같은 스키마를 쓴다 */
export function createMailEditorExtensions(hooks: Partial<MailEditorHooks> = {}): AnyExtension[] {
const kitWithLink = StarterKit.configure({ ...STARTER_KIT_OPTIONS, link: LINK_OPTIONS })
@@ -245,6 +252,7 @@ export function createMailEditorExtensions(hooks: Partial<MailEditorHooks> = {})
TableKit.configure({ table: { resizable: false }, tableCell: false, tableHeader: false }),
MailTableCell,
MailTableHeader,
SignatureImage,
Placeholder.configure({ placeholder: EDITOR_PLACEHOLDER }),
MailEditorBehavior.configure({
openLink: hooks.openLink ?? null,
@@ -158,8 +158,14 @@ function styleAll(root: ParentNode, selector: string, style: string) {
root.querySelectorAll(selector).forEach((element) => prependStyle(element, style))
}
/** 글자도 줄바꿈도 그림(서명 그림)도 없는 문단 — 받는 쪽에서 높이가 0 이 되기 쉽다 */
function isEmptyParagraph(element: Element): boolean {
return element.tagName === 'P' && element.textContent === '' && element.querySelector('br') === null
return (
element.tagName === 'P' &&
element.textContent === '' &&
element.querySelector('br') === null &&
element.querySelector('img') === null
)
}
/**
@@ -0,0 +1,84 @@
import { createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { describe, expect, it } from 'vitest'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import {
hasSignatureImage,
signatureImageIdsOf,
signatureImagesOf,
} from '@/components/mail/editor/signature-image'
import { createSignatureEditorExtensions } from '@/components/mail/editor/signature/signature-extensions'
import { IMAGE_SAMPLES, SAMPLE_IMAGE_ID } from '@/test-utils/outgoing-style-samples'
const schema = getSchema(createMailEditorExtensions())
const signatureSchema = getSchema(createSignatureEditorExtensions())
const SRC = `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`
function parse(html: string, target = schema): ProseMirrorNode {
return createDocument(normalizeIncomingHtml(html), target)
}
function editorHtml(doc: ProseMirrorNode): string {
return getHTMLFromFragment(doc.content, doc.type.schema)
}
describe('서명 그림 노드 — 서명 그림만 받는다 (계약 2절 "서식 본문"의 그림 예외)', () => {
it('서명 그림 주소의 img 만 남기고, 주소 · 폭 · 설명만 그린다', () => {
const doc = parse(`<p>서명<img src="${SRC}" width="144" alt="로고" class="x" style="width:9px" onerror="alert(1)"></p>`)
expect(signatureImagesOf(doc)).toEqual([{ src: SRC, width: 144, alt: '로고' }])
expect(editorHtml(doc)).toBe(`<p>서명<img src="${SRC}" width="144" alt="로고"></p>`)
})
it.each([
'https://evil.example/t.png',
'data:image/png;base64,iVBORw0KGgo=',
`/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`,
`cid:${SAMPLE_IMAGE_ID}`,
'/api/mail-signature/images/not-a-uuid',
])('그 밖의 그림은 빠진다 — %s', (src) => {
const doc = parse(`<p>앞<img src="${src}">뒤</p>`)
expect(hasSignatureImage(doc)).toBe(false)
expect(editorHtml(doc)).toBe('<p>앞뒤</p>')
})
it('표본 표의 폭 · 설명 — 서버가 남기지 않는 값은 그 속성만 뺀다', () => {
for (const [attribute, value, kept] of IMAGE_SAMPLES) {
if (attribute === 'src') continue
const doc = parse(`<p><img src="${SRC}" ${attribute}="${value.replace(/"/g, '&quot;')}"></p>`)
const [image] = signatureImagesOf(doc)
const read = attribute === 'width' ? image?.width : image?.alt
expect(read !== null && read !== undefined, `${attribute}=${value}`).toBe(kept)
}
})
it('주소는 서버가 다시 쓰는 모양(앞뒤 공백 없이 · id 소문자)으로 읽는다', () => {
const doc = parse(`<p><img src=" /api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()} " width=" 96 "></p>`)
expect(signatureImagesOf(doc)).toEqual([{ src: SRC, width: 96, alt: null }])
expect(signatureImageIdsOf(doc)).toEqual(new Set([SAMPLE_IMAGE_ID]))
})
it('서명 칸 스키마도 같은 규칙이다 — 목록 · 표 · 글꼴은 글자만 남는다', () => {
const doc = parse(
`<ul><li><p>항목</p></li></ul><p><span style="font-family: Arial; color: #B42318">빨강</span><img src="${SRC}" width="96"></p>`,
signatureSchema,
)
const html = editorHtml(doc)
expect(html).toMatch(/^<p>항목<\/p><p><span style="color: [^"]+">빨강<\/span><img src="[^"]+" width="96"><\/p>$/)
expect(html).not.toContain('font-family')
})
})
describe('보낼 모양 — 그림은 서식 본문에만', () => {
it('텍스트 대체 본문에는 그림이 없다', () => {
expect(docToText(parse(`<p>김아라</p><p><img src="${SRC}" width="144" alt=""></p><p>끝</p>`))).toBe('김아라\n\n끝')
})
it('그림만 있는 문단에 줄바꿈을 더하지 않는다 — 빈 문단만 높이를 지킨다', () => {
const html = toMailHtml(editorHtml(parse(`<p><img src="${SRC}" width="144" alt=""></p><p></p><p>끝</p>`)))
expect(html).toContain(`<p style="margin: 0"><img src="${SRC}" width="144" alt=""></p>`)
expect(html).toContain('<p style="margin: 0"><br></p>')
})
})
@@ -0,0 +1,104 @@
// 서명 그림 — 쓰기 본문 · 서명 칸이 받는 유일한 그림 (계약 2절 "서식 본문"의 그림 예외 · 디자인 README "2단계 › 서명")
// <img src="/api/mail-signature/images/{id}" width="144" alt=""> 만 읽고 그린다 — 주소 · 폭 · 설명은 서버 정화와 같은 형식만 받는다
// (shared/utils/outgoing-html-rules.ts 의 그림 규칙). 그 밖의 그림(외부 주소 · data: · 다른 경로)은 스키마가 받지 않아 빠진다.
// 그림 파일을 본문에 붙여 넣거나 끌어다 놓으면 여전히 첨부로 올린다 (MailEditor 의 attachPasted · attachDropped).
// 글 안의 한 글자처럼 다룬다(inline atom) — 지우기 · 옮기기(끌기) · 고르기가 글자와 같다. 고르면 서명 칸이 크기 줄을 보인다.
import { Node } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import {
imageAltOf,
imageWidthOf,
normalizeSignatureImageSrc,
signatureImageIdOf,
} from '@/shared/utils/outgoing-html-rules'
export const SIGNATURE_IMAGE_NODE = 'signatureImage'
/** 그림 노드의 속성 — 형식이 틀린 폭 · 설명은 null(그 속성을 그리지 않는다) */
export interface SignatureImageAttributes {
src: string
width: number | null
alt: string | null
}
export const SignatureImage = Node.create({
name: SIGNATURE_IMAGE_NODE,
group: 'inline',
inline: true,
atom: true,
selectable: true,
draggable: true,
addAttributes() {
return {
src: {
default: null,
// 서버가 다시 쓰는 모양으로 — 앞뒤 공백 없이, id 는 소문자
parseHTML: (element: HTMLElement) => normalizeSignatureImageSrc(element.getAttribute('src')),
renderHTML: (attributes: Record<string, unknown>) => {
const src = normalizeSignatureImageSrc(attributes.src)
return src === null ? {} : { src }
},
},
width: {
default: null,
parseHTML: (element: HTMLElement) => imageWidthOf(element.getAttribute('width')),
renderHTML: (attributes: Record<string, unknown>) => {
const width = imageWidthOf(attributes.width)
return width === null ? {} : { width: String(width) }
},
},
alt: {
default: null,
parseHTML: (element: HTMLElement) => imageAltOf(element.getAttribute('alt')),
renderHTML: (attributes: Record<string, unknown>) => {
const alt = imageAltOf(attributes.alt)
return alt === null ? {} : { alt }
},
},
}
},
parseHTML() {
return [
{
tag: 'img[src]',
// 서명 그림 주소가 아닌 그림은 읽지 않는다 — 노드 없이 빠진다
getAttrs: (element: HTMLElement) =>
normalizeSignatureImageSrc(element.getAttribute('src')) === null ? false : null,
},
]
},
renderHTML({ HTMLAttributes }) {
return ['img', HTMLAttributes]
},
})
/** 문서 안의 서명 그림 — 나온 순서대로 */
export function signatureImagesOf(doc: ProseMirrorNode): SignatureImageAttributes[] {
const images: SignatureImageAttributes[] = []
doc.descendants((node) => {
if (node.type.name !== SIGNATURE_IMAGE_NODE) return true
const src = normalizeSignatureImageSrc(node.attrs.src)
if (src !== null) images.push({ src, width: imageWidthOf(node.attrs.width), alt: imageAltOf(node.attrs.alt) })
return false
})
return images
}
/** 문서에 서명 그림이 있는가 — 글자 없이 그림만 있어도 보낼 내용이 있다 */
export function hasSignatureImage(doc: ProseMirrorNode): boolean {
return signatureImagesOf(doc).length > 0
}
/** 문서가 쓰는 서명 그림 id — 겹치지 않게 */
export function signatureImageIdsOf(doc: ProseMirrorNode): Set<string> {
const ids = new Set<string>()
for (const image of signatureImagesOf(doc)) {
const id = signatureImageIdOf(image.src)
if (id !== null) ids.add(id)
}
return ids
}
@@ -0,0 +1,398 @@
<script setup lang="ts">
// 서명 칸 — 툴바 + 편집 영역 + 그림 크기 줄 (디자인 원본 Settings · README "2단계 › 서명")
// - 글꼴은 쓰기 화면 본문과 같은 맑은 고딕 14px · 줄 간격 1.75 (보이는 모양 = 쓰기 화면에 들어가는 모양)
// - 그림: 붙여 넣거나 끌어다 놓은 파일은 넣지 않고 files 로 넘긴다 — 서명 화면이 검사해 올린 뒤 insertImage 로 넣는다.
// 고른 그림(노드 선택) 아래에 크기 줄: 작게 96 · 보통 144 · 원본(원래 폭, 600 이하) · 그림 빼기
// - 내용이 바뀌면 change 로 문서를 넘긴다 — 미리 보기가 그 문서를 그린다
// - 바꿨는가(isChanged)는 처음 불러온 서명, 저장한 뒤에는 저장한 서명(markSaved)과 견준다 — 서명 화면의 나가기 확인
// TipTap 은 이 폴더(components/mail/editor/) 안에서만 쓴다 — 서명 화면이 지연 로딩한다.
import { Editor, getHTMLFromFragment } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { NodeSelection } from '@tiptap/pm/state'
import { computed, markRaw, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import type { EditorSavePoint, MailEditorContent } from '@/components/mail/editor/mail-editor-handle'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import {
SIGNATURE_IMAGE_NODE,
hasSignatureImage,
signatureImageIdsOf,
} from '@/components/mail/editor/signature-image'
import { createSignatureEditorExtensions } from '@/components/mail/editor/signature/signature-extensions'
import SignatureToolbar from '@/components/mail/editor/signature/SignatureToolbar.vue'
import { SIGNATURE_TEXT, type SignatureImageSize } from '@/shared/constants/mail-signature'
import type { SignatureImage } from '@/shared/types/mail'
import { pastedFiles } from '@/shared/utils/editor-files'
import { imageSizeOf, imageWidthFor, insertedImageWidth } from '@/shared/utils/mail-signature'
import { imageWidthOf, signatureImageIdOf, signatureImageSrc } from '@/shared/utils/outgoing-html-rules'
interface Props {
/** 처음 내용 — 저장한 서명 HTML */
initialHtml?: string
/** 서명 칸의 이름표 id (aria-labelledby) */
labelledBy: string
/** 아래 안내 · 오류 id (aria-describedby) */
describedBy?: string | null
/** 알고 있는 그림 — 원본 폭을 찾는다 (서명이 쓰는 그림 + 이번에 올린 그림) */
images: ReadonlyMap<string, SignatureImage>
/** 그림을 더 넣을 수 없다 (올리는 중) */
imageDisabled?: boolean
/** 파일을 끌고 있다 — 칸 위에 덮개를 보인다 */
dragging?: boolean
}
interface Emits {
(e: 'ready', doc: ProseMirrorNode): void
/** 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
(e: 'change', doc: ProseMirrorNode): void
/** 붙여 넣거나 끌어다 놓은 파일 — 그림으로 올린다 */
(e: 'files', files: File[]): void
/** 그림 단추 — 파일 고르기를 연다 */
(e: 'pick-image'): void
}
const props = withDefaults(defineProps<Props>(), {
initialHtml: '',
describedBy: null,
imageDisabled: false,
dragging: false,
})
const emit = defineEmits<Emits>()
/** 화면에 하나뿐인 서명 칸 */
const SIGNATURE_BODY_ID = 'signature-body'
const IMAGE_SIZES: readonly SignatureImageSize[] = ['small', 'medium', 'original']
const bodyRef = ref<HTMLElement | null>(null)
const toolbarRef = ref<InstanceType<typeof SignatureToolbar> | null>(null)
const revision = ref<number>(0)
let mounted = false
/**
* "바꾸지 않은" 기준 — 처음 불러온(저장한 뒤에는 저장한) 문서. settled 는 편집기가 첫 거래에 스스로 붙이는
* 끝 빈 줄까지 붙인 모양이다(StarterKit 의 TrailingNode — 쓰기 본문 MailEditor 와 같다)
*/
let baseline: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
const extensions = createSignatureEditorExtensions({
openLink: () => toolbarRef.value?.openLink(),
focusToolbar: () => toolbarRef.value?.focusToolbar(),
})
const editor = markRaw(
new Editor({
element: null,
extensions,
content: props.initialHtml ? normalizeIncomingHtml(props.initialHtml) : '',
editorProps: {
attributes: () => ({
id: SIGNATURE_BODY_ID,
role: 'textbox',
'aria-multiline': 'true',
'aria-labelledby': props.labelledBy,
...(props.describedBy ? { 'aria-describedby': props.describedBy } : {}),
}),
handlePaste: (_view, event) => {
const files = pastedFiles(event.clipboardData)
if (files.length === 0) return false
emit('files', files)
return true
},
handleDrop: (_view, event, _slice, moved) => {
if (moved) return false
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length === 0) return false
emit('files', files)
return true
},
transformPastedHTML: normalizeIncomingHtml,
},
onTransaction: () => {
revision.value += 1
},
onUpdate: ({ editor: current }) => {
emit('change', markRaw(current.state.doc))
},
}),
)
/** 고른 그림 — 노드 선택일 때만 (크기 줄을 보인다) */
const selectedImage = computed(() => {
void revision.value
const { selection } = editor.state
if (!(selection instanceof NodeSelection) || selection.node.type.name !== SIGNATURE_IMAGE_NODE) return null
const id = signatureImageIdOf(selection.node.attrs.src)
const known = id ? props.images.get(id) : undefined
return {
pos: selection.from,
width: imageWidthOf(selection.node.attrs.width),
naturalWidth: known?.width ?? null,
}
})
const selectedSize = computed(() =>
selectedImage.value ? imageSizeOf(selectedImage.value.width, selectedImage.value.naturalWidth) : null,
)
/** 그림 폭을 바꾼다 — 노드를 바꾸지 않고 속성만 (고른 상태가 그대로 남는다) */
function setImageSize(size: SignatureImageSize) {
const image = selectedImage.value
if (!image) return
// 원래 폭을 모르면(남이 만든 서명 등) 원본은 지금 그림의 화면 폭으로 본다
const natural = image.naturalWidth ?? naturalWidthOnScreen(image.pos)
const width = imageWidthFor(size, natural)
editor
.chain()
.command(({ tr }) => {
tr.setNodeAttribute(image.pos, 'width', width)
return true
})
.run()
}
function naturalWidthOnScreen(pos: number): number {
const dom = mounted ? editor.view.nodeDOM(pos) : null
return dom instanceof HTMLImageElement && dom.naturalWidth > 0 ? dom.naturalWidth : imageWidthFor('medium', 0)
}
function removeSelectedImage() {
if (!selectedImage.value) return
editor.chain().focus().deleteSelection().run()
}
/** 올린 그림을 커서 자리에 넣고 고른다 — 크기 줄이 바로 보인다 */
function insertImage(image: SignatureImage) {
const src = signatureImageSrc(image.id)
if (src === null) return
const { from } = editor.state.selection
editor
.chain()
.focus()
.insertContent({ type: SIGNATURE_IMAGE_NODE, attrs: { src, width: insertedImageWidth(image.width), alt: '' } })
.setNodeSelection(from)
.run()
}
/** 서명의 보낼 모양 — 쓰기 본문과 같은 서식 본문 · 텍스트. 비었으면 둘 다 비운다 */
function getContent(): MailEditorContent {
const { doc } = editor.state
const text = docToText(doc)
if (text === '' && !hasSignatureImage(doc)) return { text: '', html: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)) }
}
/** 서명이 쓰는 그림 id */
function imageIds(): Set<string> {
return signatureImageIdsOf(editor.state.doc)
}
/** 불러온(저장한) 뒤에 서명을 바꿨는가 — 지웠다 다시 써 같아졌으면 바꾸지 않은 것이다 */
function isChanged(): boolean {
if (!baseline) return false
const { doc } = editor.state
return !doc.eq(baseline.doc) && !doc.eq(baseline.settled)
}
/** 저장할 때의 문서 — 저장이 끝나면 markSaved 로 돌려줘 기준으로 삼는다 (저장하는 동안 더 고친 것은 바꿈으로 남는다) */
function captureSavePoint(): EditorSavePoint {
return editor.state.doc as unknown as EditorSavePoint
}
function markSaved(savePoint: EditorSavePoint) {
const doc = savePoint as unknown as ProseMirrorNode
baseline = { doc, settled: doc }
}
function focus() {
if (mounted) editor.view.focus()
}
onMounted(() => {
if (bodyRef.value) {
editor.mount(bodyRef.value)
mounted = true
}
revision.value += 1
// 붙이면서 켜진 플러그인이 붙일 것(끝 빈 줄)까지 미리 본다 — 화면에 보내지 않는다
const { state } = editor
baseline = { doc: state.doc, settled: state.apply(state.tr).doc }
emit('ready', markRaw(editor.state.doc))
})
onBeforeUnmount(() => {
editor.destroy()
})
// 오류 안내가 생기거나 사라지면 서명 칸의 aria 속성을 다시 그린다
watch(
() => props.describedBy,
() => {
if (mounted && !editor.isDestroyed) editor.view.updateState(editor.state)
},
)
defineExpose({ getContent, insertImage, imageIds, focus, isChanged, captureSavePoint, markSaved })
</script>
<template>
<div :class="['signature-editor', { 'is-dragging': dragging }]">
<SignatureToolbar
ref="toolbarRef"
:editor="editor"
:revision="revision"
:controls="SIGNATURE_BODY_ID"
:image-disabled="imageDisabled"
@pick-image="emit('pick-image')"
/>
<div class="signature-editor__frame">
<div
ref="bodyRef"
class="signature-editor__body"
/>
<div
v-if="dragging"
class="signature-editor__drop"
aria-hidden="true"
>
<AppIcon name="image" />
</div>
</div>
<div
v-if="selectedImage"
class="signature-editor__sizes"
>
<span
id="signature-image-size-label"
class="signature-editor__sizes-label"
>{{ SIGNATURE_TEXT.imageSizeLabel }}</span>
<div
role="group"
aria-labelledby="signature-image-size-label"
class="segmented"
>
<button
v-for="size in IMAGE_SIZES"
:key="size"
type="button"
class="segmented__item"
:data-size="size"
:aria-pressed="selectedSize === size"
@mousedown.prevent
@click="setImageSize(size)"
>
{{ SIGNATURE_TEXT.imageSizes[size] }}
</button>
</div>
<button
type="button"
class="signature-editor__remove"
@mousedown.prevent
@click="removeSelectedImage"
>
{{ SIGNATURE_TEXT.removeImage }}
</button>
</div>
</div>
</template>
<style scoped>
/* 서명 칸 — 테두리 상자 안에 툴바 · 편집 영역 · 그림 크기 줄 (디자인 원본 Settings) */
.signature-editor {
display: flex;
flex-direction: column;
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--radius-lg);
background: var(--color-surface);
}
/* 서명 칸에 초점 — 테두리 상자가 브랜드색 (글자 입력칸 포커스와 같은 색) */
.signature-editor:has(.ProseMirror:focus) {
border-color: var(--color-brand);
box-shadow: 0 0 0 var(--border-width) var(--color-brand);
}
.signature-editor__frame {
position: relative;
}
.signature-editor__body :deep(.ProseMirror) {
min-height: 8.25rem;
padding: var(--space-14) var(--space-16);
font-family: var(--font-family-mail-body);
font-size: var(--font-size-body);
line-height: var(--line-height-mail-body);
color: var(--color-text-body);
overflow-wrap: anywhere;
}
.signature-editor__body :deep(.ProseMirror:focus),
.signature-editor__body :deep(.ProseMirror:focus-visible) {
outline: none;
}
.signature-editor__body :deep(.ProseMirror p) {
margin: 0;
}
.signature-editor__body :deep(.ProseMirror a) {
color: var(--color-brand);
}
.signature-editor__body :deep(.ProseMirror img) {
max-width: 100%;
height: auto;
vertical-align: bottom;
}
/* 고른 그림 — 브랜드색 고리 (디자인 원본 Settings) */
.signature-editor__body :deep(.ProseMirror img.ProseMirror-selectednode) {
outline: var(--focus-ring-width) solid var(--color-brand);
outline-offset: var(--focus-ring-offset);
}
/* 끌어다 놓는 동안의 덮개 — 쓰기 본문과 같은 점선 */
.signature-editor__drop {
position: absolute;
inset: var(--space-8);
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
border: var(--focus-ring-width) dashed var(--color-brand);
border-radius: var(--radius-md);
background: var(--color-drop-surface);
color: var(--color-brand-strong);
}
/* 그림 크기 줄 — 그림을 고르면 칸 아래에 */
.signature-editor__sizes {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-6) var(--space-10);
padding: var(--space-6) var(--space-12) var(--space-8);
border-top: var(--border-width) solid var(--color-border-subtle);
}
.signature-editor__sizes-label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 그림 빼기 — 크기 단추와 같은 36 (캔버스 확정값, 44px 기준의 밀도 예외) */
.signature-editor__remove {
min-height: var(--size-segment-item-lg);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text-strong);
font-size: var(--font-size-label);
font-weight: 500;
}
.signature-editor__remove:hover {
background: var(--color-surface-muted);
color: var(--color-text);
}
</style>
@@ -0,0 +1,465 @@
<script setup lang="ts">
// 메일 서명 설정 (디자인 원본 Settings · README "2단계 › 서명") — 설정 › 메일 › 서명 화면이 지연 로딩한다 (TipTap 이 이 청크에만 있다)
// 위에서부터 서명 칸(툴바 · 편집 영역 · 그림 크기 줄) → 안내 → 서명을 넣을 메일(새 메일 · 답장 · 전달) → [저장] → 미리 보기.
// - 그림: 그림 단추 · 붙여 넣기 · 끌어다 놓기(화면 어디든) → 사전 검사(PNG · JPG · GIF, 300KB, 3장) → POST /mail-signature/images →
// 커서 자리에 보통(144) 폭으로 넣는다. 걸리면(또는 서버가 형식 · 크기로 거절하면) 서명 칸 아래에 사전 문구 (FILE_001 · FILE_002 · VAL_001).
// 3장은 서명 칸에 있는 서로 다른 그림으로 센다 — 뺀 그림은 세지 않는다 (서명 저장의 한도, 계약 2절 "서명").
// 올려 두기만 한 그림까지 한 사람 10장은 서버가 올리기에서 막는다(VAL_001) — 그 알림은 인터셉터가 띄운다.
// - 저장: PUT /mail-signature { bodyHtml(쓰기 본문과 같은 인라인 서식), body(텍스트 서명), isForNew, isForReply } —
// 비었으면 둘 다 null. 길이 한도(서식 10,000 · 텍스트 2,000)는 보내기 전에 먼저 막는다(VAL_001).
// 저장하면 "저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다." — 무엇이든 다시 바꾸면 내린다.
// - 나가기 확인: 처음 불러온(저장한 뒤에는 저장한) 서명과 다르면 앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창
// (useLeaveConfirm). 올리는 중인 그림도 바꿈이다. 저장하는 중 · 세션이 끝났을 때는 앱 안 이동을 묻지 않는다.
// 고정 id · 이름 — 화면 검증 · 테스트가 쓴다: 서명 칸 #signature-body · 그림 고르기 #signature-image-input ·
// 새 메일 #signature-for-new · 답장 · 전달 #signature-for-reply · 저장 #signature-save · 상태 #signature-save-status ·
// 오류 #signature-error · 크기 단추 [data-size] · 미리 보기 단추 [data-mode] ·
// 나가기 확인 #signature-leave-dialog · #signature-leave-stay(계속 고치기) · #signature-leave-confirm(나가기)
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref, shallowRef, watch } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import LeaveConfirmDialog from '@/components/feedback/LeaveConfirmDialog.vue'
import SignatureEditor from '@/components/mail/editor/signature/SignatureEditor.vue'
import SignaturePreview from '@/components/mail/editor/signature/SignaturePreview.vue'
import { getErrorMessage, toApiError } from '@/composables/useApi'
import { useLeaveConfirm } from '@/composables/useLeaveConfirm'
import { useMail } from '@/composables/useMail'
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
import {
SIGNATURE_HTML_MAX_LENGTH,
SIGNATURE_IMAGE_ACCEPT,
SIGNATURE_IMAGE_INLINE_CODES,
SIGNATURE_LEAVE_DIALOG,
SIGNATURE_SAVE_INLINE_CODES,
SIGNATURE_TEXT,
SIGNATURE_TEXT_MAX_LENGTH,
} from '@/shared/constants/mail-signature'
import type { MailSignature, SignatureImage } from '@/shared/types/mail'
import { renameFile } from '@/shared/utils/editor-files'
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
import { precheckSignatureImage, signatureImageUploadName } from '@/shared/utils/mail-signature'
import { useAuthStore } from '@/stores/auth.store'
import { useMailSignatureStore } from '@/stores/mail-signature.store'
interface Props {
/** 서버의 서명 — 이 값으로 칸을 채운다 */
signature: MailSignature
}
const props = defineProps<Props>()
const { uploadSignatureImage } = useMail()
const authStore = useAuthStore()
const signatureStore = useMailSignatureStore()
const LABEL_ID = 'signature-label'
const HINT_ID = 'signature-hint'
const ERROR_ID = 'signature-error'
/** 처음 내용 — 서식 서명, 없으면 텍스트 서명을 문단으로 (다른 메일 프로그램이 텍스트만 남긴 경우) */
const initialHtml =
props.signature.bodyHtml ?? (props.signature.body ? textToParagraphHtml(props.signature.body) : '')
const editorRef = ref<InstanceType<typeof SignatureEditor> | null>(null)
const fileInputRef = ref<HTMLInputElement | null>(null)
const forNew = ref<boolean>(props.signature.isForNew)
const forReply = ref<boolean>(props.signature.isForReply)
/** 미리 보기가 그릴 문서 — 편집기가 바뀔 때마다 */
const previewDoc = shallowRef<ProseMirrorNode | null>(null)
const saveState = ref<'idle' | 'saving' | 'saved'>('idle')
/** 서명 칸 아래 오류 — 사전 문구 */
const errorMessage = ref<string | null>(null)
/** 올리는 중인 그림 수 */
const uploading = ref<number>(0)
/** 알고 있는 그림 — 원본 폭을 찾는다 */
const images = shallowRef<ReadonlyMap<string, SignatureImage>>(
new Map(props.signature.images.map((image) => [image.id, image])),
)
/** 불러온(저장한 뒤에는 저장한) 뒤 서명 칸을 바꿨다 — 편집기가 잰다 */
const editorChanged = ref<boolean>(false)
/** 불러온(저장한) 때의 "넣을 메일" — 이것과 다르면 바꾼 것이다 */
const savedOptions = shallowRef({ forNew: props.signature.isForNew, forReply: props.signature.isForReply })
const describedBy = computed(() => (errorMessage.value ? `${HINT_ID} ${ERROR_ID}` : HINT_ID))
/** 떠나면 잃는 것이 있다 — 저장하지 않은 서명 칸 · 넣을 메일, 올리는 중인 그림 */
const hasUnsavedChanges = computed(
() =>
editorChanged.value ||
uploading.value > 0 ||
forNew.value !== savedOptions.value.forNew ||
forReply.value !== savedOptions.value.forReply,
)
/**
* 떠나기 전 확인 — 앱 안 이동은 대화상자, 창 닫기 · 새로고침은 브라우저 확인창.
* 저장하는 중에는 앱 안 이동을 묻지 않는다(저장은 이어진다). 세션이 끝났으면 묻지 않는다 — 그 흐름이 로그인 화면으로 보낸다.
*/
const { confirming: leaveConfirming, stay: stayOnForm, leave: leaveForm } = useLeaveConfirm({
active: () => hasUnsavedChanges.value && saveState.value !== 'saving' && authStore.isLoggedIn,
unloadActive: () => hasUnsavedChanges.value && authStore.isLoggedIn,
})
/** 무엇이든 바꾸면 "저장했습니다"를 내린다 */
function markChanged() {
if (saveState.value === 'saved') saveState.value = 'idle'
}
function onEditorReady(doc: ProseMirrorNode) {
previewDoc.value = doc
}
function onEditorChange(doc: ProseMirrorNode) {
previewDoc.value = doc
editorChanged.value = editorRef.value?.isChanged() ?? false
markChanged()
}
/** 그림 수 — 서명 칸에 있는 서로 다른 그림과 올리는 중인 그림 (뺀 그림은 세지 않는다) */
function occupiedImageCount(): number {
return (editorRef.value?.imageIds().size ?? 0) + uploading.value
}
/** 그림 한 장을 검사해 올리고 넣는다 — 실패는 칸 아래에 사전 문구 */
async function addImage(file: File) {
const rejected = precheckSignatureImage(file, occupiedImageCount())
if (rejected) {
errorMessage.value = getErrorMessage(rejected)
return
}
errorMessage.value = null
uploading.value += 1
try {
const named = signatureImageUploadName(file)
const image = await uploadSignatureImage(named === file.name ? file : renameFile(file, named), {
silent: SIGNATURE_IMAGE_INLINE_CODES,
})
images.value = new Map([...images.value, [image.id, image] as const])
editorRef.value?.insertImage(image)
markChanged()
} catch (error) {
const apiError = toApiError(error)
// 형식 · 크기는 칸 아래에 — 그 밖(올려 둔 그림 10장 초과 VAL_001 등)은 인터셉터가 알렸다
if (SIGNATURE_IMAGE_INLINE_CODES.includes(apiError.code)) errorMessage.value = apiError.message
} finally {
uploading.value -= 1
}
}
/** 여러 장이면 차례로 — 앞의 그림이 개수에 들어간 뒤 다음을 센다 */
async function addImages(files: File[]) {
for (const file of files) await addImage(file)
}
// 화면 어디에 그림을 놓아도 받는다 — 브라우저가 파일을 열어 쓰던 서명을 잃지 않게
const { dragging } = useWindowFileDrop((files) => void addImages(files))
function pickImage() {
fileInputRef.value?.click()
}
function onFilesChosen(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
// 같은 파일을 다시 고를 수 있게 비운다
input.value = ''
void addImages(files)
}
async function save() {
const editor = editorRef.value
if (!editor || saveState.value === 'saving') return
const content = editor.getContent()
// 길이 한도 — 서버도 같은 한도로 VAL_001 을 준다 (서식은 정화한 뒤 재지만 정화는 거의 줄이지 않는다)
if ((content.html?.length ?? 0) > SIGNATURE_HTML_MAX_LENGTH || content.text.length > SIGNATURE_TEXT_MAX_LENGTH) {
errorMessage.value = getErrorMessage('VAL_001')
editor.focus()
return
}
errorMessage.value = null
saveState.value = 'saving'
// 저장할 때의 모양 — 저장이 끝나면 이것을 "바꾸지 않은" 기준으로 삼는다 (저장하는 동안 더 고친 것은 바꿈으로 남는다)
const savePoint = editor.captureSavePoint()
const options = { forNew: forNew.value, forReply: forReply.value }
try {
const saved = await signatureStore.save(
{
bodyHtml: content.html,
body: content.text === '' ? null : content.text,
isForNew: options.forNew,
isForReply: options.forReply,
},
{ silent: SIGNATURE_SAVE_INLINE_CODES },
)
images.value = new Map([...images.value, ...saved.images.map((image) => [image.id, image] as const)])
editorRef.value?.markSaved(savePoint)
editorChanged.value = editorRef.value?.isChanged() ?? false
savedOptions.value = options
saveState.value = 'saved'
} catch (error) {
saveState.value = 'idle'
const apiError = toApiError(error)
if (SIGNATURE_SAVE_INLINE_CODES.includes(apiError.code)) errorMessage.value = apiError.message
}
}
watch([forNew, forReply], markChanged)
</script>
<template>
<div class="signature-form">
<div class="signature-form__field">
<p
:id="LABEL_ID"
class="signature-form__label"
>
{{ SIGNATURE_TEXT.fieldLabel }}
</p>
<SignatureEditor
ref="editorRef"
:initial-html="initialHtml"
:labelled-by="LABEL_ID"
:described-by="describedBy"
:images="images"
:image-disabled="uploading > 0"
:dragging="dragging"
@ready="onEditorReady"
@change="onEditorChange"
@files="addImages"
@pick-image="pickImage"
/>
<input
id="signature-image-input"
ref="fileInputRef"
type="file"
multiple
class="visually-hidden"
tabindex="-1"
aria-hidden="true"
:accept="SIGNATURE_IMAGE_ACCEPT"
@change="onFilesChosen"
>
<p
v-if="errorMessage"
:id="ERROR_ID"
class="signature-form__alert"
role="alert"
>
<AppIcon
name="alert-circle"
:size="15"
/>
<span>{{ errorMessage }}</span>
</p>
<p
v-if="uploading > 0"
class="signature-form__uploading"
role="status"
>
<AppIcon
name="loader"
:size="14"
:stroke-width="2"
/>
<span>{{ SIGNATURE_TEXT.uploading }}</span>
</p>
<p
:id="HINT_ID"
class="signature-form__hint"
>
{{ SIGNATURE_TEXT.hint }}
</p>
</div>
<fieldset class="signature-form__usage">
<legend class="signature-form__legend">
{{ SIGNATURE_TEXT.usageLegend }}
</legend>
<label class="signature-form__option">
<input
id="signature-for-new"
v-model="forNew"
type="checkbox"
class="signature-form__checkbox"
>
<span>{{ SIGNATURE_TEXT.forNew }}</span>
</label>
<label class="signature-form__option">
<input
id="signature-for-reply"
v-model="forReply"
type="checkbox"
class="signature-form__checkbox"
>
<span>{{ SIGNATURE_TEXT.forReply }}</span>
<span class="signature-form__option-note">{{ SIGNATURE_TEXT.forReplyNote }}</span>
</label>
</fieldset>
<div class="signature-form__actions">
<button
id="signature-save"
type="button"
class="signature-form__save"
:disabled="saveState === 'saving' || uploading > 0"
:aria-busy="saveState === 'saving'"
@click="save"
>
{{ saveState === 'saving' ? SIGNATURE_TEXT.saving : SIGNATURE_TEXT.save }}
</button>
<span
id="signature-save-status"
class="signature-form__status"
role="status"
>
<template v-if="saveState === 'saved'">
<AppIcon
name="check"
:size="15"
:stroke-width="2.2"
/>
<span>{{ SIGNATURE_TEXT.saved }}</span>
</template>
</span>
</div>
<hr class="signature-form__rule">
<SignaturePreview
:doc="previewDoc"
:for-new="forNew"
:for-reply="forReply"
/>
<!-- 저장하지 않은 채 떠나려 할 때 — 답할 때까지 이동이 기다린다 (useLeaveConfirm) -->
<LeaveConfirmDialog
:open="leaveConfirming"
id-prefix="signature-leave"
:copy="SIGNATURE_LEAVE_DIALOG"
@stay="stayOnForm"
@leave="leaveForm"
/>
</div>
</template>
<style scoped>
.signature-form {
display: flex;
flex-direction: column;
gap: var(--space-22);
}
.signature-form__field {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.signature-form__label {
margin: 0;
font-weight: 600;
}
.signature-form__alert {
display: flex;
align-items: center;
gap: var(--space-8);
margin: 0;
font-size: var(--font-size-label);
color: var(--color-danger);
}
.signature-form__uploading {
display: flex;
align-items: center;
gap: var(--space-6);
margin: 0;
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.signature-form__hint {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
.signature-form__usage {
display: flex;
flex-direction: column;
margin: 0;
padding: 0;
border: 0;
}
.signature-form__legend {
margin: 0 0 var(--space-4);
padding: 0;
font-weight: 600;
color: var(--color-text);
}
.signature-form__option {
display: flex;
align-items: center;
gap: var(--space-10);
min-height: var(--size-touch);
cursor: pointer;
}
.signature-form__checkbox {
width: 1.125rem;
height: 1.125rem;
margin: 0;
accent-color: var(--color-brand);
cursor: pointer;
}
.signature-form__option-note {
color: var(--color-text-muted);
}
.signature-form__actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-12);
}
.signature-form__save {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-22);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-size: var(--font-size-emphasis);
font-weight: 600;
}
.signature-form__save:hover:not(:disabled) {
background: var(--color-brand-strong);
}
.signature-form__save:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.signature-form__status {
display: inline-flex;
align-items: center;
gap: var(--space-6);
color: var(--color-success-text);
font-weight: 500;
}
.signature-form__rule {
width: 100%;
height: var(--border-width);
margin: 0;
border: 0;
background: var(--color-border-subtle);
}
</style>
@@ -0,0 +1,232 @@
<script setup lang="ts">
// 서명 미리 보기 (디자인 원본 Settings) — [새 메일 | 답장] 을 바꿔 보며, 쓰기 화면에 들어갈 자리를 보인다.
// - 새 메일: (여기에 쓴 내용) · 빈 줄 · 서명 / 답장: 같은 위에 원문 인용(자리만 — 실제 메일이 아니다)
// - 서명은 편집 중인 문서를 편집기 스키마로 그린다 (DOMSerializer — 스키마가 그리는 태그 · 속성뿐이라 남의 HTML 을 넣지 않는다).
// 그 쓰기 종류에 넣지 않기로 했으면 서명을 빼고 아래 안내가 바뀐다.
import { DOMSerializer, type Node as ProseMirrorNode } from '@tiptap/pm/model'
import { computed, ref, watch } from 'vue'
import {
SIGNATURE_PREVIEW_NOTES,
SIGNATURE_PREVIEW_QUOTE,
SIGNATURE_TEXT,
} from '@/shared/constants/mail-signature'
import { MAIL_QUOTE_HEADING } from '@/shared/constants/mail-editor'
export type SignaturePreviewMode = 'new' | 'reply'
interface Props {
/** 편집 중인 서명 문서 (없으면 아직 불러오는 중) */
doc: ProseMirrorNode | null
/** 새 메일에 넣는다 */
forNew: boolean
/** 답장 · 전달에 넣는다 */
forReply: boolean
}
const props = defineProps<Props>()
const mode = ref<SignaturePreviewMode>('new')
const signatureRef = ref<HTMLElement | null>(null)
const MODES: readonly { mode: SignaturePreviewMode; label: string }[] = [
{ mode: 'new', label: SIGNATURE_TEXT.previewNew },
{ mode: 'reply', label: SIGNATURE_TEXT.previewReply },
]
/** 이 쓰기 종류에 서명을 넣는다 */
const shown = computed(() => (mode.value === 'reply' ? props.forReply : props.forNew))
const note = computed(() => {
if (mode.value === 'reply') return props.forReply ? SIGNATURE_PREVIEW_NOTES.replyOn : SIGNATURE_PREVIEW_NOTES.replyOff
return props.forNew ? SIGNATURE_PREVIEW_NOTES.newOn : SIGNATURE_PREVIEW_NOTES.newOff
})
/** 서명 문서를 그린다 — 스키마의 toDOM 결과만 붙인다 */
function render() {
const target = signatureRef.value
if (!target) return
target.replaceChildren()
const { doc } = props
if (!doc) return
const fragment = DOMSerializer.fromSchema(doc.type.schema).serializeFragment(doc.content, { document })
target.append(fragment)
}
watch([() => props.doc, signatureRef], render, { flush: 'post', immediate: true })
</script>
<template>
<section
class="signature-preview"
aria-labelledby="signature-preview-title"
>
<div class="signature-preview__head">
<h2
id="signature-preview-title"
class="signature-preview__title"
>
{{ SIGNATURE_TEXT.previewTitle }}
</h2>
<div
role="group"
:aria-label="SIGNATURE_TEXT.previewSwitchLabel"
class="segmented"
>
<button
v-for="option in MODES"
:key="option.mode"
type="button"
class="segmented__item signature-preview__mode"
:data-mode="option.mode"
:aria-pressed="mode === option.mode"
@click="mode = option.mode"
>
{{ option.label }}
</button>
</div>
</div>
<div class="signature-preview__mail">
<p class="signature-preview__placeholder">
{{ SIGNATURE_TEXT.previewBody }}
</p>
<p><br></p>
<div
v-show="shown"
ref="signatureRef"
class="signature-preview__signature"
data-signature-preview
/>
<blockquote
v-if="mode === 'reply'"
class="signature-preview__quote"
>
<div class="signature-preview__quote-head">
<p class="signature-preview__quote-heading">
{{ MAIL_QUOTE_HEADING }}
</p>
<table class="signature-preview__quote-table">
<tbody>
<tr
v-for="row in SIGNATURE_PREVIEW_QUOTE.rows"
:key="row.label"
>
<th scope="row">
{{ row.label }}
</th>
<td>{{ row.value }}</td>
</tr>
</tbody>
</table>
</div>
<p>{{ SIGNATURE_PREVIEW_QUOTE.body }}</p>
</blockquote>
</div>
<p
class="signature-preview__note"
data-signature-preview-note
>
{{ note }}
</p>
</section>
</template>
<style scoped>
.signature-preview {
display: flex;
flex-direction: column;
gap: var(--space-12);
}
.signature-preview__head {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-8) var(--space-14);
}
.signature-preview__title {
margin: 0;
font-size: var(--font-size-body);
font-weight: 700;
}
.signature-preview__mode {
padding: 0 var(--space-14);
}
/* 받는 사람이 보는 본문과 같은 글꼴 · 크기 · 줄 간격 */
.signature-preview__mail {
padding: var(--space-16) var(--space-20) var(--space-20);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-lg);
background: var(--color-surface);
font-family: var(--font-family-mail-body);
font-size: var(--font-size-body);
line-height: var(--line-height-mail-body);
color: var(--color-text-body);
overflow-wrap: anywhere;
}
.signature-preview__mail p,
.signature-preview__signature :deep(p) {
margin: 0;
}
.signature-preview__placeholder {
color: var(--color-text-muted);
}
.signature-preview__signature :deep(a) {
color: var(--color-brand);
}
.signature-preview__signature :deep(img) {
max-width: 100%;
height: auto;
vertical-align: bottom;
}
/* 원문 인용 — 쓰기 화면의 인용 막대와 같은 값 */
.signature-preview__quote {
margin: var(--space-24) 0 0;
padding: var(--space-2) 0 var(--space-2) var(--space-14);
border-left: var(--focus-ring-width) solid var(--color-quote-bar);
color: var(--color-text-secondary);
}
.signature-preview__quote-head {
margin: 0 0 var(--space-12);
font-size: var(--font-size-label);
line-height: 1.6;
}
.signature-preview__quote-heading {
font-weight: 600;
}
.signature-preview__quote-table {
border-collapse: collapse;
font-size: var(--font-size-label);
line-height: 1.6;
}
.signature-preview__quote-table th {
padding: 0 var(--space-12) 0 0;
text-align: left;
vertical-align: top;
font-weight: 400;
color: var(--color-text-muted);
white-space: nowrap;
}
.signature-preview__quote-table td {
padding: 0;
}
.signature-preview__note {
margin: 0;
font-size: var(--font-size-label);
color: var(--color-text-muted);
}
</style>
@@ -0,0 +1,296 @@
<script setup lang="ts">
// 서명 칸 툴바 (디자인 원본 Settings) — 굵게 · 기울임 · 밑줄 | 글자 색 · 링크 · 그림
// 쓰기 본문 툴바(EditorToolbar)와 같은 단추 · 팝오버를 쓴다:
// - role="toolbar": Tab 한 번에 들어와 화살표 · Home · End 로 옮긴다(로빙 tabindex). Esc → 서명 칸, 서명 칸에서 Alt+F10 → 툴바
// - 키보드로 누른 단추는 초점을 툴바에 두고, 마우스로 누른 단추와 팝오버에서 고른 값은 서명 칸으로 돌아간다
// 그림 단추는 파일 고르기를 연다 — 올리기는 서명 화면(SignatureForm)이 한다 (pick-image).
import type { ChainedCommands, Editor } from '@tiptap/core'
import { computed, nextTick, ref } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
import EditorColorPicker from '@/components/mail/editor/EditorColorPicker.vue'
import EditorLinkForm from '@/components/mail/editor/EditorLinkForm.vue'
import EditorToolButton from '@/components/mail/editor/EditorToolButton.vue'
import { applyLink, readLinkTarget, removeLink, type LinkTarget } from '@/components/mail/editor/editor-links'
import type { LinkFormValue } from '@/components/mail/editor/editor-menu'
import { readToolbarStatus } from '@/components/mail/editor/editor-state'
import { EDITOR_SHORTCUTS, EDITOR_TEXT_COLORS, type EditorColor } from '@/shared/constants/mail-editor'
interface Props {
editor: Editor
/** 편집기 상태가 바뀔 때마다 늘어나는 값 — 툴바 표시를 다시 읽는다 */
revision: number
/** 서명 칸 id — aria-controls */
controls: string
/** 그림을 더 넣을 수 없다 (올리는 중 등) */
imageDisabled?: boolean
}
interface Emits {
/** 그림 넣기 — 파일 고르기를 연다 */
(e: 'pick-image'): void
}
const props = withDefaults(defineProps<Props>(), {
imageDisabled: false,
})
const emit = defineEmits<Emits>()
type ToolId = 'bold' | 'italic' | 'underline' | 'textColor' | 'link' | 'image'
const TOOL_ORDER: readonly ToolId[] = ['bold', 'italic', 'underline', 'textColor', 'link', 'image']
type PopupId = 'textColor' | 'link'
const rootRef = ref<HTMLElement | null>(null)
const activeTool = ref<ToolId>('bold')
/** 열린 팝오버 — 키보드로 열었으면 닫을 때 그 단추로, 마우스로 열었으면 서명 칸으로 */
const popup = ref<{ id: PopupId; returnFocus: 'toolbar' | 'editor'; focusItems: boolean } | null>(null)
const linkTarget = ref<LinkTarget | null>(null)
const status = computed(() => {
// 편집기 상태는 반응형이 아니다 — revision 이 바뀔 때마다 다시 읽는다
void props.revision
return readToolbarStatus(props.editor)
})
const marks = computed(() => [
{ id: 'bold' as const, label: '굵게', icon: 'bold' as const, pressed: status.value.bold, run: (chain: ChainedCommands) => chain.toggleBold() },
{ id: 'italic' as const, label: '기울임', icon: 'italic' as const, pressed: status.value.italic, run: (chain: ChainedCommands) => chain.toggleItalic() },
{ id: 'underline' as const, label: '밑줄', icon: 'underline' as const, pressed: status.value.underline, run: (chain: ChainedCommands) => chain.toggleUnderline() },
])
const focusableTools = computed(() => TOOL_ORDER.filter((id) => id !== 'image' || !props.imageDisabled))
const rovingTool = computed<ToolId>(() =>
focusableTools.value.includes(activeTool.value) ? activeTool.value : 'bold',
)
function toolElement(id: ToolId): HTMLElement | null {
return rootRef.value?.querySelector<HTMLElement>(`[data-tool="${id}"]`) ?? null
}
function isFocusOnToolbar(): boolean {
const active = document.activeElement
return Boolean(active && rootRef.value?.contains(active) && !active.closest('[data-editor-popup]'))
}
function focusTool(id: ToolId) {
activeTool.value = id
toolElement(id)?.focus()
}
function focusEditor() {
props.editor.view.focus()
}
function runTool(command: (chain: ChainedCommands) => ChainedCommands) {
const byKeyboard = isFocusOnToolbar()
command(props.editor.chain()).run()
if (!byKeyboard) focusEditor()
}
function openPopup(id: PopupId, returnFocus: 'toolbar' | 'editor', focusItems: boolean) {
linkTarget.value = id === 'link' ? readLinkTarget(props.editor) : null
popup.value = { id, returnFocus, focusItems }
}
function togglePopup(id: PopupId) {
if (popup.value?.id === id) {
closePopup(id, true)
return
}
const byKeyboard = isFocusOnToolbar()
openPopup(id, byKeyboard ? 'toolbar' : 'editor', byKeyboard)
}
function closePopup(id: PopupId, restoreFocus: boolean) {
const open = popup.value
if (open?.id !== id) return
popup.value = null
if (!restoreFocus) return
if (id === 'link' || open.returnFocus === 'editor') {
focusEditor()
return
}
void nextTick(() => focusTool(id))
}
function onTextColorPick(color: EditorColor) {
popup.value = null
const chain = props.editor.chain()
;(color.value === null ? chain.unsetColor() : chain.setColor(color.value)).run()
focusEditor()
}
function onLinkApply(value: LinkFormValue) {
const target = linkTarget.value
popup.value = null
if (target) applyLink(props.editor, target, value)
focusEditor()
}
function onLinkRemove() {
const target = linkTarget.value
popup.value = null
if (target) removeLink(props.editor, target)
focusEditor()
}
function onKeydown(event: KeyboardEvent) {
const target = event.target instanceof HTMLElement ? event.target : null
if (!target || target.closest('[data-editor-popup]')) return
const tools = focusableTools.value
const current = tools.indexOf(target.dataset.tool as ToolId)
if (current < 0) return
const last = tools.length - 1
if (event.key === 'Escape') {
event.preventDefault()
focusEditor()
return
}
const moves: Record<string, number> = {
ArrowRight: current === last ? 0 : current + 1,
ArrowLeft: current === 0 ? last : current - 1,
Home: 0,
End: last,
}
const next = moves[event.key]
if (next === undefined) return
event.preventDefault()
const tool = tools[next]
if (tool) focusTool(tool)
}
function onFocusIn(event: FocusEvent) {
const tool = event.target instanceof HTMLElement ? event.target.dataset.tool : undefined
if (tool && TOOL_ORDER.includes(tool as ToolId)) activeTool.value = tool as ToolId
}
defineExpose({
/** 서명 칸의 Alt+F10 — 툴바로 */
focusToolbar: () => focusTool(rovingTool.value),
/** 서명 칸의 Ctrl+K — 링크 팝오버 */
openLink: () => openPopup('link', 'editor', true),
})
</script>
<template>
<div
ref="rootRef"
class="signature-toolbar"
role="toolbar"
aria-label="서명 서식"
:aria-controls="controls"
@keydown="onKeydown"
@focusin="onFocusIn"
>
<EditorToolButton
v-for="item in marks"
:key="item.id"
:tool="item.id"
:label="item.label"
:shortcut="EDITOR_SHORTCUTS[item.id]"
:pressed="item.pressed"
:focusable="rovingTool === item.id"
@press="runTool(item.run)"
>
<AppIcon :name="item.icon" />
</EditorToolButton>
<span
class="signature-toolbar__separator"
aria-hidden="true"
/>
<div class="signature-toolbar__anchor">
<EditorToolButton
tool="textColor"
:label="`글자 색: ${status.textColor.name}`"
title="글자 색"
haspopup="dialog"
:expanded="popup?.id === 'textColor'"
:focusable="rovingTool === 'textColor'"
@press="togglePopup('textColor')"
>
<EditorColorGlyph
kind="text"
:color="status.textColor.value"
/>
</EditorToolButton>
<EditorColorPicker
v-if="popup?.id === 'textColor'"
title="글자 색"
:palette="EDITOR_TEXT_COLORS"
:current="status.textColor.color"
:trigger="toolElement('textColor')"
:focus-content="!popup.focusItems"
@pick="onTextColorPick"
@close="(restore) => closePopup('textColor', restore)"
/>
</div>
<div class="signature-toolbar__anchor">
<EditorToolButton
tool="link"
label="링크"
:shortcut="EDITOR_SHORTCUTS.link"
haspopup="dialog"
:expanded="popup?.id === 'link'"
:focusable="rovingTool === 'link'"
@press="togglePopup('link')"
>
<AppIcon name="link" />
</EditorToolButton>
<EditorLinkForm
v-if="popup?.id === 'link'"
:editing="linkTarget?.editing ?? false"
:initial-href="linkTarget?.href ?? ''"
:initial-text="linkTarget?.text ?? ''"
:trigger="toolElement('link')"
align="start"
@apply="onLinkApply"
@remove="onLinkRemove"
@close="(restore) => closePopup('link', restore)"
/>
</div>
<EditorToolButton
tool="image"
label="그림 넣기"
title="그림 넣기 — PNG · JPG · GIF"
:disabled="imageDisabled"
:focusable="rovingTool === 'image'"
@press="emit('pick-image')"
>
<AppIcon name="image" />
</EditorToolButton>
</div>
</template>
<style scoped>
/* 높이 52 (위아래 4) · 아래 선 — 디자인 원본 Settings 의 서명 칸 머리 */
.signature-toolbar {
position: relative;
z-index: 2;
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-2);
min-height: var(--size-editor-toolbar);
padding: var(--space-4) var(--space-8);
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
/* 팝오버가 이 단추 아래에 붙는다 */
.signature-toolbar__anchor {
position: relative;
display: flex;
}
.signature-toolbar__separator {
flex: 0 0 auto;
width: var(--border-width);
height: var(--space-20);
margin: 0 var(--space-4);
background: var(--color-border);
}
</style>
@@ -0,0 +1,68 @@
// 서명 칸(TipTap) 확장 구성 — 디자인 README "2단계 › 서명" (디자인 원본 Settings)
// 쓸 수 있는 것: 문단 · 줄바꿈, 굵게 · 기울임 · 밑줄, 글자 색, 링크, 서명 그림. 그 밖(목록 · 표 · 글꼴 · 글자 크기 등)은
// 스키마에 없어 붙여 넣어도 글자만 남는다. 값 형식은 쓰기 본문과 같다 (서버 정화 허용 목록 — mail-format-extensions.ts).
// 서명은 쓰기 화면을 열 때 본문에 그대로 들어가므로, 이 스키마는 쓰기 본문 스키마(mail-extensions.ts)의 일부여야 한다.
import { Extension, type AnyExtension } from '@tiptap/core'
import { TextStyle } from '@tiptap/extension-text-style'
import { StarterKit } from '@tiptap/starter-kit'
import { createMailLinkExtension } from '@/components/mail/editor/mail-extensions'
import { MailColor } from '@/components/mail/editor/mail-format-extensions'
import { SignatureImage } from '@/components/mail/editor/signature-image'
export interface SignatureEditorHooks {
/** Ctrl+K — 링크 넣기 · 고치기 */
openLink: (() => void) | null
/** Alt+F10 — 툴바로 간다 */
focusToolbar: (() => void) | null
}
const SignatureBehavior = Extension.create<SignatureEditorHooks>({
name: 'signatureEditor',
addOptions() {
return { openLink: null, focusToolbar: null }
},
addKeyboardShortcuts() {
return {
'Mod-k': () => {
this.options.openLink?.()
return true
},
'Alt-F10': () => {
this.options.focusToolbar?.()
return true
},
}
},
})
/** StarterKit 에서 쓰지 않는 것 — 제목 · 코드 · 목록 · 인용 · 가로줄 · 취소선 (링크는 따로 넣는다) */
const STARTER_KIT_OPTIONS = {
heading: false,
code: false,
codeBlock: false,
blockquote: false,
bulletList: false,
orderedList: false,
listItem: false,
listKeymap: false,
horizontalRule: false,
strike: false,
link: false,
} as const
export function createSignatureEditorExtensions(hooks: Partial<SignatureEditorHooks> = {}): AnyExtension[] {
return [
StarterKit.configure(STARTER_KIT_OPTIONS),
createMailLinkExtension(),
TextStyle,
MailColor,
SignatureImage,
SignatureBehavior.configure({
openLink: hooks.openLink ?? null,
focusToolbar: hooks.focusToolbar ?? null,
}),
]
}
@@ -0,0 +1,14 @@
// 받는 사람 추천 목록의 고정 id — 입력칸(RecipientField)과 목록(RecipientSuggestionList)이 함께 쓴다 (화면 검증 · 테스트도 쓴다)
// 목록 #{inputId}-suggestions · 추천 #{inputId}-option-{n} · 안내 #{inputId}-suggestions-hint
export function suggestionListId(inputId: string): string {
return `${inputId}-suggestions`
}
export function suggestionHintId(inputId: string): string {
return `${inputId}-suggestions-hint`
}
export function suggestionOptionId(inputId: string, index: number): string {
return `${inputId}-option-${index}`
}
@@ -20,19 +20,25 @@ describe('AppRail', () => {
expect(labels).toEqual(APP_MODULES.map((module) => module.label))
})
it('지금은 메일만 링크이고 현재 모듈로 표시된다', async () => {
it('메일 · 설정이 링크이고, 지금 모듈이 현재로 표시된다', async () => {
const wrapper = await mountRail()
const current = wrapper.get('a[aria-current="page"]')
const links = wrapper.findAll('a.rail-item').map((link) => [link.text(), link.attributes('href')])
expect(current.text()).toBe('메일')
expect(current.attributes('href')).toBe('/mail')
// 설정은 첫 메뉴(메일 › 서명)로 바꿔 끼운다
expect(links).toEqual([
['메일', '/mail'],
['설정', '/settings'],
])
})
it('나머지는 "준비 중" 비활성 칸이다 (뱃지 없이 툴팁·스크린리더 문구로)', async () => {
const wrapper = await mountRail()
const disabled = wrapper.findAll('button[aria-disabled="true"]')
expect(disabled).toHaveLength(APP_MODULES.length - 1)
expect(disabled).toHaveLength(APP_MODULES.length - 2)
for (const item of disabled) {
expect(item.attributes('title')).toContain('준비 중')
expect(item.get('.visually-hidden').text()).toBe('(준비 중)')