generated from ttipo/project-base
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:
@@ -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 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
|
||||
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
|
||||
|
||||
@@ -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 } }),
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -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, '"')}"></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('(준비 중)')
|
||||
|
||||
@@ -60,6 +60,10 @@ describe('에러 코드 사전', () => {
|
||||
'MAIL_005', // 같은 키·다른 내용 — 쓰기 화면이 발송 키를 새로 만든다
|
||||
'MAIL_006', // 첨부 20개 초과 — 첨부 사전 검사가 사전 문구를 직접 꺼내 쓴다
|
||||
'MAIL_007', // 본문이 너무 김 — 쓰기 화면이 본문 아래 띠로 안내한다 (보내기 전 검사 · 서버 거절 모두)
|
||||
// 웹메일 2단계
|
||||
'MAIL_008', // 다른 곳에서 바뀐 임시 저장본 — 쓰기 화면이 새 저장본으로 남기고 정보 상태 글을 보인다
|
||||
'MAIL_001', // 메일 서버 장애 — 임시 저장이 조금 뒤 저절로 다시 해 본다
|
||||
'MAIL_003', // 메일함 용량 — 임시 저장이 조금 뒤 저절로 다시 해 본다
|
||||
'FILE_001', // 첨부 형식 사전 검사
|
||||
'FILE_002', // 첨부 크기 사전 검사 · 413 역매핑
|
||||
]
|
||||
|
||||
@@ -411,4 +411,58 @@ describe('useAttachmentUploads', () => {
|
||||
stop()
|
||||
})
|
||||
})
|
||||
|
||||
describe('임시 저장 뒤 첨부 id 바꾸기 (계약 2절 "임시 저장" — 저장할 때마다 첨부 id 가 바뀐다)', () => {
|
||||
const SAVED = (id: string, name: string) => ({ id, name, type: 'application/pdf', size: 1 })
|
||||
|
||||
it('저장 본문에 실은 순서대로 응답의 id 로 바꾼다 — 화면의 이름 · 키는 그대로', async () => {
|
||||
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
|
||||
const { uploads, stop } = withUploads()
|
||||
uploads.addFiles([file('회의록.pdf')])
|
||||
await flushPromises()
|
||||
const [forward] = uploads.addExisting([SAVED('orig-1', '체크리스트.xlsx')])
|
||||
const keys = uploads.items.value.map((item) => item.key)
|
||||
|
||||
uploads.applySavedAttachments(keys, [SAVED('up1-v1', '회의록.pdf'), SAVED('orig-1-v1', '체크리스트.xlsx')])
|
||||
|
||||
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['up1-v1', 'orig-1-v1'])
|
||||
expect(uploads.items.value.map((item) => item.key)).toEqual(keys)
|
||||
expect(uploads.items.value[1]?.key).toBe(forward?.key)
|
||||
stop()
|
||||
})
|
||||
|
||||
it('이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이고 응답 순서가 달라도', async () => {
|
||||
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
|
||||
const { uploads, stop } = withUploads()
|
||||
const added = uploads.addExisting([
|
||||
{ id: 'small', name: '보고서.pdf', type: 'application/pdf', size: 10 },
|
||||
{ id: 'large', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
|
||||
{ id: 'sheet', name: '표.xlsx', type: 'application/vnd.ms-excel', size: 5 },
|
||||
])
|
||||
const keys = added.map((item) => item.key)
|
||||
|
||||
uploads.applySavedAttachments(keys, [
|
||||
{ id: 'large-v1', name: '보고서.pdf', type: 'application/pdf', size: 2048 },
|
||||
// 서버가 형식을 고쳐 돌려줬다 — 남은 것끼리 순서대로
|
||||
{ id: 'sheet-v1', name: '표.xlsx', type: 'application/octet-stream', size: 5 },
|
||||
{ id: 'small-v1', name: '보고서.pdf', type: 'application/pdf', size: 10 },
|
||||
])
|
||||
|
||||
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['small-v1', 'large-v1', 'sheet-v1'])
|
||||
stop()
|
||||
})
|
||||
|
||||
it('개수가 다르면 같은 자리 · 같은 이름끼리만 — 저장하는 동안 뺀 첨부는 건너뛴다', async () => {
|
||||
installMockApi(() => ok(SAVED('up1', '회의록.pdf'), 201))
|
||||
const { uploads, stop } = withUploads()
|
||||
const added = uploads.addExisting([SAVED('a', '가.pdf'), SAVED('b', '나.pdf')])
|
||||
const keys = added.map((item) => item.key)
|
||||
|
||||
uploads.remove(keys[1] ?? -1)
|
||||
uploads.applySavedAttachments(keys, [SAVED('a-v1', '가.pdf')])
|
||||
|
||||
expect(uploads.uploaded.value.map((attachment) => attachment.id)).toEqual(['a-v1'])
|
||||
stop()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface AttachmentUploadOptions {
|
||||
|
||||
const COMPLETE = 100
|
||||
|
||||
/** 같은 파일로 보이는가 — 이름 · 크기 · 형식이 모두 같다 */
|
||||
function isSameFile(a: Attachment, b: Attachment): boolean {
|
||||
return a.name === b.name && a.size === b.size && a.type === b.type
|
||||
}
|
||||
|
||||
/**
|
||||
* 첨부 상태 — 파일마다 진행률을 보이고 실패한 파일만 표시한다.
|
||||
* 화면(스코프)이 사라지면 올리던 요청을 모두 취소한다.
|
||||
@@ -205,6 +210,43 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
return marked
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장이 끝났다 — 저장본의 첨부 id 로 바꿔 쓴다 (계약 2절 "임시 저장": 저장할 때마다 첨부 id 가 바뀐다).
|
||||
* keys 는 저장 본문에 실은 첨부 항목의 키(실은 순서대로), saved 는 응답의 첨부다.
|
||||
* 이름 · 크기 · 형식이 같은 것끼리 먼저 맞춘다 — 같은 이름이 여럿이어도 응답의 순서에만 기대지 않게.
|
||||
* 남은 것은 순서대로 — 남은 수가 같으면 같은 자리끼리, 다르면 같은 자리 · 같은 이름끼리만 바꾼다.
|
||||
* 저장하는 동안 뺀 항목은 이미 없고(그 자리는 건너뛴다), 새로 붙인 항목은 다음 저장에 실린다.
|
||||
*/
|
||||
function applySavedAttachments(keys: readonly number[], saved: readonly Attachment[]) {
|
||||
const sent = keys.map((key) => items.value.find((candidate) => candidate.key === key))
|
||||
const next = new Map<number, Attachment>()
|
||||
const usedSaved = new Set<number>()
|
||||
const matchedSent = new Set<number>()
|
||||
sent.forEach((item, position) => {
|
||||
const current = item?.attachment
|
||||
if (!item || !current) return
|
||||
const index = saved.findIndex((candidate, at) => !usedSaved.has(at) && isSameFile(current, candidate))
|
||||
const attachment = saved[index]
|
||||
if (!attachment) return
|
||||
usedSaved.add(index)
|
||||
matchedSent.add(position)
|
||||
next.set(item.key, attachment)
|
||||
})
|
||||
const restSent = sent.filter((_, position) => !matchedSent.has(position))
|
||||
const restSaved = saved.filter((_, at) => !usedSaved.has(at))
|
||||
const sameCount = restSent.length === restSaved.length
|
||||
restSent.forEach((item, index) => {
|
||||
const attachment = restSaved[index]
|
||||
if (!attachment || !item?.attachment) return
|
||||
if (sameCount || item.attachment.name === attachment.name) next.set(item.key, attachment)
|
||||
})
|
||||
if (next.size === 0) return
|
||||
items.value = items.value.map((item) => {
|
||||
const attachment = next.get(item.key)
|
||||
return attachment && item.status === 'done' ? { ...item, attachment } : item
|
||||
})
|
||||
}
|
||||
|
||||
/** 첨부 취소 — 올리는 중이면 요청도 끊는다 */
|
||||
function remove(key: number) {
|
||||
controllers.get(key)?.abort()
|
||||
@@ -233,6 +275,7 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
attachedCount,
|
||||
addFiles,
|
||||
addExisting,
|
||||
applySavedAttachments,
|
||||
markMissing,
|
||||
remove,
|
||||
reset,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { inject, type InjectionKey } from 'vue'
|
||||
|
||||
// 시계 — 기다렸다 하는 일(임시 저장의 입력 멈춤 · 받는 사람 추천의 기다림)이 쓰는 시각과 타이머.
|
||||
// 화면은 기본으로 브라우저 시계를 쓰고, 테스트는 손으로 돌리는 시계를 provide 해 시간을 정확히 넘긴다
|
||||
// (test-utils/manual-clock.ts — 진짜 타이머를 기다리면 느리고, 부하에 따라 결과가 달라진다).
|
||||
|
||||
/** 타이머 손잡이 — 시계마다 모양이 다르므로 열어 보지 않는다 */
|
||||
export type ClockTimer = ReturnType<typeof setTimeout> | number | symbol
|
||||
|
||||
export interface Clock {
|
||||
/** 지금 시각 (밀리초) */
|
||||
now: () => number
|
||||
setTimeout: (callback: () => void, ms: number) => ClockTimer
|
||||
clearTimeout: (timer: ClockTimer) => void
|
||||
}
|
||||
|
||||
export const systemClock: Clock = {
|
||||
now: () => Date.now(),
|
||||
setTimeout: (callback, ms) => setTimeout(callback, ms),
|
||||
clearTimeout: (timer) => clearTimeout(timer as ReturnType<typeof setTimeout>),
|
||||
}
|
||||
|
||||
/** 화면 트리에 시계를 준다 — 주지 않으면 브라우저 시계 */
|
||||
export const CLOCK: InjectionKey<Clock> = Symbol('clock')
|
||||
|
||||
/** 컴포넌트 setup 에서 부른다 */
|
||||
export function useClock(): Clock {
|
||||
return inject(CLOCK, systemClock)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { onBeforeUnmount } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
|
||||
import { saveDraftDetached } from './useDraftSaving'
|
||||
import type { DraftSaved, SendMessagePayload } from '@/shared/types/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 쓰기 화면과 서버 저장본 id 를 잇는다 (계약 2절 "임시 저장" — 저장할 때마다 id 가 바뀐다 · 디자인 README "2단계 › 임시 저장")
|
||||
// - 주소 맞추기(sync): 저장할 때마다 /mail/compose?draft=<새 id> 로 바꿔 끼운다 — 새로고침 · 다시 열기가 마지막 저장본을 이어 쓴다.
|
||||
// 새 메일 · 답장 · 전달 · 옛 draft 매개변수를 모두 이것으로 바꾼다. 스스로 바꾼 주소는 ownsKey 로 알아본다 —
|
||||
// 쓰기 화면은 그 주소로 양식을 다시 채우지도, 떠나는 이동으로 보지도 않는다.
|
||||
// 다른 이동이 진행 중이면 바꾸지 않는다 — 그 이동을 취소해 버린다: 쓰기 화면이 막는 때(canReplace — 나가기 확인 · 떠나기 전 저장 ·
|
||||
// 보내기 · 버리기 등)와, 라우터 가드를 지나 화면이 바뀌는 중인 다른 이동(여기서 센다).
|
||||
// - 옛 id → 새 id(recordSaved): 저장할 때마다 탭 메모리(mailStore)에 적는다 — 옛 주소(?draft=<옛 id>, 떠나기 전 저장처럼
|
||||
// 주소를 맞추지 못한 채 떠난 경우)로 열면 마지막 저장본을 연다(openDraft).
|
||||
// - 보내는 중에 떠났고 보내지 못했으면 보내려던 내용을 조용히 저장본으로 남긴다(keepUnsent).
|
||||
// 쓰기 화면의 setup 에서 부른다 — 라우터 훅을 걸고, 화면을 내리면 뗀다.
|
||||
|
||||
export interface ComposeDraftLinkOptions {
|
||||
/** 지금 주소를 바꿔도 되는가 — 쓰기 화면의 다른 흐름(나가기 확인 · 보내기 · 버리기 · 보낸 뒤 · 버린 뒤)이 아니다 */
|
||||
canReplace: () => boolean
|
||||
}
|
||||
|
||||
/** 이 화면이 주소에 쓰는 저장본 표시 — 쓰기 화면의 draftKeyOf 와 같은 모양 */
|
||||
function draftKeyFor(id: string): string {
|
||||
return `draft:${id}`
|
||||
}
|
||||
|
||||
export function useComposeDraftLink(options: ComposeDraftLinkOptions) {
|
||||
const router = useRouter()
|
||||
const mailStore = useMailStore()
|
||||
|
||||
/**
|
||||
* 이 양식의 주소 — 연 때의 쓰기 종류 · 원본, 저장한 뒤에는 바꿔 끼운 `draft:<id>`.
|
||||
* 주소가 이것으로 바뀐 것은 이 화면이 스스로 바꾼 것이다
|
||||
*/
|
||||
let ownKey = ''
|
||||
/** 이 화면이 바꿔 끼우는 중인 주소 — 다른 이동을 세는 데서 뺀다 */
|
||||
let ownSyncPath: string | null = null
|
||||
/**
|
||||
* 라우터의 가드를 지난 다른 이동 수 — 화면이 바뀌기 전(지연 로딩한 화면을 받는 중 등)에 주소를 바꿔 끼우면 그 이동이 취소된다.
|
||||
* 이 화면의 이동 가드(떠나기 전 저장 · 대화상자)를 기다리는 동안은 쓰기 화면의 canReplace 가 맡는다 (그 가드가 라우터 전체 가드보다 먼저 돈다)
|
||||
*/
|
||||
let navigationsPending = 0
|
||||
|
||||
const stopCountingStart = router.beforeEach((to) => {
|
||||
if (to.fullPath !== ownSyncPath) navigationsPending += 1
|
||||
})
|
||||
const stopCountingEnd = router.afterEach((to, _from, failure) => {
|
||||
const own = ownSyncPath !== null && to.fullPath === ownSyncPath
|
||||
if (own) ownSyncPath = null
|
||||
// 끝까지 간 이동이 있으면 그 앞의 이동은 남아 있지 않다(라우터가 앞 이동을 취소한다). 실패로 끝난 이동은 하나만 뺀다
|
||||
if (!failure) navigationsPending = 0
|
||||
else if (!own) navigationsPending = Math.max(0, navigationsPending - 1)
|
||||
})
|
||||
// 이동이 오류로 끝나면 afterEach 가 오지 않는다 — 세던 것을 비운다
|
||||
const stopCountingError = router.onError(() => {
|
||||
navigationsPending = 0
|
||||
})
|
||||
|
||||
/** 이 화면이 스스로 바꾼(또는 연) 주소인가 */
|
||||
function ownsKey(key: string): boolean {
|
||||
return key === ownKey
|
||||
}
|
||||
|
||||
/** 이 주소의 양식을 연다 — 이제 이 주소가 이 화면의 것이다 */
|
||||
function adoptKey(key: string) {
|
||||
ownKey = key
|
||||
}
|
||||
|
||||
/** 주소를 /mail/compose?draft=<id> 로 바꿔 끼운다 — 이 화면이 스스로 바꾼 것으로 표시한다. 이미 그 주소면 그대로 */
|
||||
function replace(id: string) {
|
||||
ownKey = draftKeyFor(id)
|
||||
const current = router.currentRoute.value
|
||||
if (current.query.draft === id && Object.keys(current.query).length === 1) return
|
||||
const target = router.resolve({ name: 'mail-compose', query: { draft: id } })
|
||||
ownSyncPath = target.fullPath
|
||||
void router.replace(target)
|
||||
}
|
||||
|
||||
/** 저장한 저장본을 주소에 옮긴다 — 다른 이동이 진행 중이거나 쓰기 화면을 떠났으면 바꾸지 않는다 */
|
||||
function sync(id: string | null) {
|
||||
if (!id || !options.canReplace() || navigationsPending > 0) return
|
||||
if (router.currentRoute.value.name !== 'mail-compose') return
|
||||
replace(id)
|
||||
}
|
||||
|
||||
/** 이어 쓸 저장본 — 이 탭에서 다시 저장해 바뀐 옛 id 면 마지막 저장본을 고르고 주소도 그것으로 바꾼다 */
|
||||
function openDraft(requested: string): string {
|
||||
const latest = mailStore.latestDraftId(requested)
|
||||
if (latest !== requested) replace(latest)
|
||||
return latest
|
||||
}
|
||||
|
||||
/** 저장이 끝났다 — 옛 id → 새 id 를 적는다 (양식이 바뀐 뒤 끝난 저장도 — 탭 메모리 정보라 해가 없다) */
|
||||
function recordSaved(previousId: string | null, saved: DraftSaved) {
|
||||
if (previousId !== null) mailStore.recordDraftSuccessor(previousId, saved.id)
|
||||
}
|
||||
|
||||
/**
|
||||
* 보내지 못한 메일을 저장본으로 남긴다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때(양식이 이미 없다).
|
||||
* 발송 본문의 draftId 가 있으면 그 저장본을 PUT(바뀌었거나 없으면 POST), 없으면 POST — 자동 저장과 같은 규칙. 알리지 않는다
|
||||
*/
|
||||
async function keepUnsent(payload: SendMessagePayload) {
|
||||
const { draftId = null, ...draft } = payload
|
||||
const saved = await saveDraftDetached(draft, draftId)
|
||||
if (!saved) return
|
||||
recordSaved(draftId, saved)
|
||||
mailStore.syncCounts()
|
||||
}
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopCountingStart()
|
||||
stopCountingEnd()
|
||||
stopCountingError()
|
||||
})
|
||||
|
||||
return { ownsKey, adoptKey, sync, openDraft, recordSaved, keepUnsent }
|
||||
}
|
||||
@@ -0,0 +1,447 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import { useDraftSaving, type DraftSaveRequest } from '@/composables/useDraftSaving'
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockHandler,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
|
||||
const PAYLOAD: DraftPayload = { to: [], cc: [], bcc: [], subject: '초안', body: '', attachments: [] }
|
||||
|
||||
function savedAs(id: string, at = '2026-10-27T06:12:00Z'): DraftSaved {
|
||||
return { id, attachments: [], updatedAt: at }
|
||||
}
|
||||
|
||||
/** 붙잡아 둔 응답 */
|
||||
interface Held {
|
||||
release: (result: MockResult) => void
|
||||
}
|
||||
|
||||
describe('useDraftSaving (임시 저장 — 계약 2절 "임시 저장")', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
/** 저장할 바뀐 내용이 있다 — 저장이 끝나면(commit) 지운다 */
|
||||
let changed: boolean
|
||||
let commits: DraftSaved[]
|
||||
let requests: MockRequest[]
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
scope = effectScope()
|
||||
clock = createManualClock(1_000_000)
|
||||
changed = false
|
||||
commits = []
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function setup(handler: MockHandler, canSave = () => true) {
|
||||
requests = installMockApi(handler)
|
||||
const drafts = scope.run(() =>
|
||||
useDraftSaving({
|
||||
canSave,
|
||||
hasChanges: () => changed,
|
||||
prepare: (): DraftSaveRequest => ({
|
||||
payload: PAYLOAD,
|
||||
commit: (saved) => {
|
||||
changed = false
|
||||
commits.push(saved)
|
||||
},
|
||||
}),
|
||||
clock,
|
||||
}),
|
||||
)
|
||||
if (!drafts) throw new Error('scope')
|
||||
return drafts
|
||||
}
|
||||
|
||||
/** 바꿨다 — 화면이 하는 것처럼 touch */
|
||||
function edit(drafts: ReturnType<typeof setup>) {
|
||||
changed = true
|
||||
drafts.touch()
|
||||
}
|
||||
|
||||
const saves = () => requests.filter((request) => request.url.startsWith('/drafts'))
|
||||
const calls = () => saves().map((request) => `${request.method} ${request.url}`)
|
||||
|
||||
/** 처음은 d1, 그다음 d2 … 를 돌려주는 서버 */
|
||||
function counting(): MockHandler {
|
||||
let next = 1
|
||||
return (request) => {
|
||||
if (!request.url.startsWith('/drafts')) return ok(null)
|
||||
const id = `d${next}`
|
||||
next += 1
|
||||
return ok(savedAs(id), request.method === 'POST' ? 201 : 200)
|
||||
}
|
||||
}
|
||||
|
||||
it('입력이 3초 멈추면 저장한다 — 계속 쓰는 동안은 미룬다', async () => {
|
||||
const drafts = setup(counting())
|
||||
edit(drafts)
|
||||
clock.advance(2_000)
|
||||
edit(drafts)
|
||||
clock.advance(2_999)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual([])
|
||||
|
||||
clock.advance(1)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
expect(drafts.draftId.value).toBe('d1')
|
||||
expect(drafts.savedAt.value).toBe('2026-10-27T06:12:00Z')
|
||||
expect(commits).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('쉬지 않고 쓰는 동안에도 처음 바꾼 뒤 30초가 지나면 저장한다 — 되돌려 바꾼 것이 없어지면 거둔다', async () => {
|
||||
const drafts = setup(counting())
|
||||
for (let elapsed = 0; elapsed < 30_000; elapsed += 1_000) {
|
||||
edit(drafts)
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
if (elapsed + 1_000 < 30_000) expect(calls()).toEqual([])
|
||||
}
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
// 바꿨다가 되돌리면(바꾼 것이 없어짐) 30초 타이머도 거둔다
|
||||
edit(drafts)
|
||||
changed = false
|
||||
drafts.touch()
|
||||
clock.advance(60_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
})
|
||||
|
||||
it('저장하는 동안 바꾼 것은 그때(저장하는 동안 처음 바꾼 때)부터 30초를 센다', async () => {
|
||||
let held: Held | null = null
|
||||
let posts = 0
|
||||
const drafts = setup((request) => {
|
||||
if (request.method === 'POST' && posts === 0) {
|
||||
posts += 1
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
}
|
||||
return ok(savedAs('d2'))
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
// 저장이 끝나지 않은 채 25초 동안 쉬지 않고 쓴다 — 30초 타이머는 저장하는 동안 처음 바꾼 때(3초)부터
|
||||
for (let elapsed = 0; elapsed < 25_000; elapsed += 1_000) {
|
||||
edit(drafts)
|
||||
clock.advance(1_000)
|
||||
}
|
||||
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
|
||||
await flushPromises()
|
||||
changed = true
|
||||
for (let elapsed = 0; elapsed < 5_000; elapsed += 1_000) {
|
||||
edit(drafts)
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
}
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
|
||||
})
|
||||
|
||||
it('바꾼 내용이 없으면 저장하지 않는다', async () => {
|
||||
const drafts = setup(counting())
|
||||
drafts.touch()
|
||||
clock.advance(60_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual([])
|
||||
expect(drafts.status.value).toBe('idle')
|
||||
})
|
||||
|
||||
it('10초에 한 번까지 — 그다음 저장은 직전 저장을 시작한 지 10초 뒤, 직전 id 로 PUT', async () => {
|
||||
const drafts = setup(counting())
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
// 첫 저장을 시작한 때(3초)부터 10초 — 13초에
|
||||
clock.advance(6_999)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
clock.advance(1)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
|
||||
expect(drafts.draftId.value).toBe('d2')
|
||||
})
|
||||
|
||||
it('한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시 저장한다', async () => {
|
||||
let held: Held | null = null
|
||||
const drafts = setup((request) => {
|
||||
if (request.method === 'POST') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
}
|
||||
return ok(savedAs('d2'))
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(drafts.status.value).toBe('saving')
|
||||
|
||||
// 저장하는 동안 또 바꾸고 3초 · 10초가 지나도 두 번째 요청은 나가지 않는다
|
||||
edit(drafts)
|
||||
clock.advance(15_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
|
||||
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
|
||||
await flushPromises()
|
||||
// 첫 저장의 commit 이 "바꿈"을 지웠지만, 그 뒤 또 바꿨다 — 화면은 다시 touch 한다
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
|
||||
})
|
||||
|
||||
it.each(['MAIL_008', 'RES_001'] as const)(
|
||||
'PUT 이 %s 면 막지 않고 지금 내용을 POST 로 새로 남긴다 — 상태 replaced, 다음은 새 id 로',
|
||||
async (code) => {
|
||||
let posts = 0
|
||||
const drafts = setup((request) => {
|
||||
if (request.method === 'POST') {
|
||||
posts += 1
|
||||
return ok(savedAs(posts === 1 ? 'd1' : 'd9'), 201)
|
||||
}
|
||||
if (request.url === '/drafts/d1') return fail(code === 'MAIL_008' ? 409 : 404, code)
|
||||
return ok(savedAs('d10'))
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
edit(drafts)
|
||||
clock.advance(10_000)
|
||||
await flushPromises()
|
||||
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1', 'POST /drafts'])
|
||||
expect(drafts.status.value).toBe('replaced')
|
||||
expect(drafts.draftId.value).toBe('d9')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(10_000)
|
||||
await flushPromises()
|
||||
expect(calls().at(-1)).toBe('PUT /drafts/d9')
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
},
|
||||
)
|
||||
|
||||
it('자동 저장이 실패하면 알림 없이 failed — 다시 해 보면 나아질 실패는 15초 뒤 저절로 다시 한다', async () => {
|
||||
let fails = 1
|
||||
const drafts = setup(() => {
|
||||
if (fails > 0) {
|
||||
fails -= 1
|
||||
return fail(503, 'MAIL_001')
|
||||
}
|
||||
return ok(savedAs('d1'), 201)
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(drafts.status.value).toBe('failed')
|
||||
expect(drafts.lastError.value?.code).toBe('MAIL_001')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
clock.advance(14_999)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(1)
|
||||
clock.advance(1)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(2)
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
})
|
||||
|
||||
it('내용을 고쳐야 하는 실패(MAIL_007 등)는 저절로 다시 하지 않는다 — 다시 바꾸면 저장한다', async () => {
|
||||
const drafts = setup(() => fail(400, 'MAIL_007'))
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
clock.advance(120_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(1)
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('「임시 저장」은 바로 저장하고, 실패하면 사전 문구 알림을 띄운다', async () => {
|
||||
const drafts = setup(() => fail(507, 'MAIL_003'))
|
||||
edit(drafts)
|
||||
await drafts.saveNow()
|
||||
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
expect(drafts.status.value).toBe('failed')
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.MAIL_003])
|
||||
})
|
||||
|
||||
it('「임시 저장」 — 아직 저장한 적 없으면 바꾼 것이 없어도 저장하고, 저장한 그대로면 요청하지 않는다', async () => {
|
||||
const drafts = setup(counting())
|
||||
await drafts.saveNow()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
await drafts.saveNow()
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
})
|
||||
|
||||
it('보내기 · 버리기 전 — suspend 로 멈추고 settle 로 진행 중인 저장이 끝나기를 기다린다', async () => {
|
||||
let held: Held | null = null
|
||||
const drafts = setup(() => {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
|
||||
drafts.suspend()
|
||||
let settled = false
|
||||
void drafts.settle().then(() => {
|
||||
settled = true
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(30_000)
|
||||
await flushPromises()
|
||||
expect(settled).toBe(false)
|
||||
expect(calls()).toHaveLength(1)
|
||||
|
||||
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
|
||||
await flushPromises()
|
||||
expect(settled).toBe(true)
|
||||
expect(drafts.draftId.value).toBe('d1')
|
||||
// 멈춘 동안은 자동 저장하지 않는다
|
||||
clock.advance(30_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('떠나기 전 — 남은 변경을 저장하고 성공 여부를 알려 준다 (저장한 그대로면 요청 없이 true)', async () => {
|
||||
const drafts = setup(counting())
|
||||
expect(await drafts.saveBeforeLeave()).toBe(true)
|
||||
expect(calls()).toEqual([])
|
||||
|
||||
edit(drafts)
|
||||
expect(await drafts.saveBeforeLeave()).toBe(true)
|
||||
expect(calls()).toEqual(['POST /drafts'])
|
||||
})
|
||||
|
||||
it('저장할 수 없는 때(canSave false)는 요청하지 않는다', async () => {
|
||||
const drafts = setup(counting(), () => false)
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(await drafts.saveNow()).toBe(false)
|
||||
expect(calls()).toEqual([])
|
||||
})
|
||||
|
||||
it('reset — 이어 쓰는 저장본이면 그 id · 시각으로 시작하고, 늦게 끝난 앞 양식의 저장은 무시한다', async () => {
|
||||
let held: Held | null = null
|
||||
const drafts = setup((request) => {
|
||||
if (request.method === 'POST') {
|
||||
return new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
}
|
||||
return ok(savedAs('d8'))
|
||||
})
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
|
||||
drafts.reset({ id: 'd7', savedAt: '2026-10-26T01:00:00Z' })
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
;(held as Held | null)?.release(ok(savedAs('d1'), 201))
|
||||
await flushPromises()
|
||||
expect(drafts.draftId.value).toBe('d7')
|
||||
expect(commits).toEqual([])
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
expect(calls().at(-1)).toBe('PUT /drafts/d7')
|
||||
})
|
||||
|
||||
it('reset(keepOriginal) — 원본을 지우지 않는다: 첫 저장은 새 저장본(POST), 그 뒤는 새 id 로 PUT. 그동안 draftId 는 없다', async () => {
|
||||
const drafts = setup(counting())
|
||||
drafts.reset({ id: 'outlook-1', savedAt: '2026-10-26T01:00:00Z', keepOriginal: true })
|
||||
expect(drafts.status.value).toBe('saved')
|
||||
expect(drafts.draftId.value).toBeNull()
|
||||
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
edit(drafts)
|
||||
clock.advance(10_000)
|
||||
await flushPromises()
|
||||
expect(calls()).toEqual(['POST /drafts', 'PUT /drafts/d1'])
|
||||
})
|
||||
|
||||
it('onSaved — 저장이 끝날 때마다 바꾼 id 와 새 저장본을 알린다 (양식이 바뀐 뒤 끝난 저장도)', async () => {
|
||||
let held: Held | null = null
|
||||
const seen: [string | null, string][] = []
|
||||
requests = installMockApi((request) =>
|
||||
request.method === 'PUT'
|
||||
? new Promise<MockResult>((resolve) => {
|
||||
held = { release: resolve }
|
||||
})
|
||||
: ok(savedAs('d1'), 201),
|
||||
)
|
||||
const drafts = scope.run(() =>
|
||||
useDraftSaving({
|
||||
canSave: () => true,
|
||||
hasChanges: () => changed,
|
||||
prepare: (): DraftSaveRequest => ({ payload: PAYLOAD, commit: () => (changed = false) }),
|
||||
onSaved: (previousId, saved) => seen.push([previousId, saved.id]),
|
||||
clock,
|
||||
}),
|
||||
)
|
||||
if (!drafts) throw new Error('scope')
|
||||
edit(drafts)
|
||||
clock.advance(3_000)
|
||||
await flushPromises()
|
||||
edit(drafts)
|
||||
clock.advance(10_000)
|
||||
await flushPromises()
|
||||
|
||||
// PUT 이 나가 있는 동안 양식을 새로 채운다 — 늦게 끝난 저장도 알린다
|
||||
drafts.reset()
|
||||
;(held as Held | null)?.release(ok(savedAs('d2')))
|
||||
await flushPromises()
|
||||
expect(seen).toEqual([
|
||||
[null, 'd1'],
|
||||
['d1', 'd2'],
|
||||
])
|
||||
expect(drafts.draftId.value).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,352 @@
|
||||
import { onScopeDispose, readonly, ref } from 'vue'
|
||||
|
||||
import { toApiError, type ApiError } from './useApi'
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import { useMail, type MailRequestOptions } from './useMail'
|
||||
import {
|
||||
DRAFT_IDLE_SAVE_MS,
|
||||
DRAFT_MAX_WAIT_MS,
|
||||
DRAFT_MIN_SAVE_INTERVAL_MS,
|
||||
DRAFT_RETRY_CODES,
|
||||
DRAFT_RETRY_DELAYS_MS,
|
||||
} from '@/shared/constants/mail'
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { DraftPayload, DraftSaved } from '@/shared/types/mail'
|
||||
|
||||
// 쓰기 화면의 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장")
|
||||
// - 자동 저장: 바꾼 내용이 있으면 입력이 3초 멈출 때, 10초에 한 번까지. 저장은 한 번에 하나씩 — 저장하는 동안 바뀐 것은 끝난 뒤 다시.
|
||||
// 쉬지 않고 쓰는 동안에도 바꾼 내용이 처음 생긴 뒤(마지막 저장을 시작한 뒤 처음 바꾼 때부터) 30초가 지나면 저장한다.
|
||||
// - 처음은 POST /drafts, 그다음은 PUT /drafts/{직전 id} — 응답의 id · 첨부로 바꿔 쓴다(prepare 가 준 commit).
|
||||
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌으면(409 MAIL_008 · 임시보관함 밖으로 옮겨져 404 RES_001) 막지 않고 POST 로 새로 남긴다
|
||||
// — 상태 'replaced' (임시보관함에 두 통이 남는다).
|
||||
// - 자동 저장 실패는 알림 없이 상태(failed)로만 보이고, 다시 해 보면 나아질 실패(DRAFT_RETRY_CODES)는 조금 뒤 저절로 다시 한다.
|
||||
// 「임시 저장」을 눌러 실패하면 사전 문구 알림도 띄운다 (인터셉터 — MAIL_008 · RES_001 은 위처럼 처리하므로 끈다).
|
||||
// - 보내기 · 버리기 전에는 suspend 로 자동 저장을 멈추고 settle 로 진행 중인 저장을 기다린다 (draftId 가 최신이어야 한다).
|
||||
|
||||
export type DraftSaveStatus = 'idle' | 'saving' | 'saved' | 'failed' | 'replaced'
|
||||
|
||||
/** 저장을 시작한 까닭 — 알림을 띄울지(manual) 정한다 */
|
||||
export type DraftSaveTrigger = 'auto' | 'manual' | 'leave'
|
||||
|
||||
/**
|
||||
* 저장할 수 없는 내용이다 — 보내기 전 검사에 걸렸다 (예: 본문이 한도를 넘음 MAIL_007).
|
||||
* 요청 없이 실패로 두고 저절로 다시 하지 않는다 — 내용이 바뀌면(touch) 다시 잰다
|
||||
*/
|
||||
export interface DraftSaveRejected {
|
||||
rejected: ApiError
|
||||
}
|
||||
|
||||
/** 저장 한 번 — 보낼 본문과, 저장이 끝났을 때 그 내용을 "저장한 것"으로 삼는 함수 */
|
||||
export interface DraftSaveRequest {
|
||||
payload: DraftPayload
|
||||
/** 저장이 끝났다 — 응답의 첨부 id 로 바꿔 쓰고, 이 요청을 만든 때의 양식을 "바꾸지 않은" 기준으로 삼는다 */
|
||||
commit: (saved: DraftSaved) => void
|
||||
}
|
||||
|
||||
export interface DraftSavingOptions {
|
||||
/** 지금 저장해도 되는가 — 편집기 준비 · 로그인 · 보내는 중 아님 등 */
|
||||
canSave: () => boolean
|
||||
/** 마지막으로 저장한(저장한 적 없으면 연) 뒤 바꾼 내용이 있다 */
|
||||
hasChanges: () => boolean
|
||||
/** 지금 내용으로 저장 요청을 만든다 — 만들 수 없으면 null, 저장할 수 없는 내용이면(사전 검사) rejected */
|
||||
prepare: () => DraftSaveRequest | DraftSaveRejected | null
|
||||
/** 저장이 실패했다 — 예: 찾지 못한 첨부(MAIL_004)를 표시한다 */
|
||||
onFailed?: (error: ApiError, trigger: DraftSaveTrigger) => void
|
||||
/**
|
||||
* 저장이 끝났다 — previousId 는 이 저장이 바꾼(PUT 한) 저장본 id, 새로 만들었으면 null.
|
||||
* 양식이 바뀐 뒤 끝난 저장도 부른다 (commit 은 부르지 않는다) — 예: 옛 id → 새 id 를 적어 둔다
|
||||
*/
|
||||
onSaved?: (previousId: string | null, saved: DraftSaved) => void
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
/** 이어 쓰는 저장본 — 쓰기 화면이 임시 저장본을 불러와 채웠을 때 */
|
||||
export interface ExistingDraft {
|
||||
id: string
|
||||
/** 마지막으로 저장한 시각 (ISO 8601) */
|
||||
savedAt: string
|
||||
/**
|
||||
* 원본을 지우지 않는다 — 첫 저장을 PUT 대신 새 저장본(POST)으로 하고, 그 뒤는 새 id 로 PUT 한다.
|
||||
* 다른 메일 프로그램이 만든 저장본에 우리가 실을 수 없는 첨부(사전 검사 실패)가 있을 때 — PUT 하면 서버가 그 첨부째 원본을 지운다.
|
||||
* 그동안 draftId 는 null 이다 (보내기도 원본을 가리키지 않는다)
|
||||
*/
|
||||
keepOriginal?: boolean
|
||||
}
|
||||
|
||||
/** 「임시 저장」을 눌렀을 때 끄는 알림 — 이 둘은 새로 저장해 처리한다 */
|
||||
const REPLACED_CODES: readonly ErrorCode[] = ['MAIL_008', 'RES_001']
|
||||
|
||||
function isRetryable(error: ApiError): boolean {
|
||||
return !error.canceled && DRAFT_RETRY_CODES.includes(error.code)
|
||||
}
|
||||
|
||||
type DraftApi = Pick<ReturnType<typeof useMail>, 'createDraft' | 'updateDraft'>
|
||||
|
||||
/**
|
||||
* 저장 요청 한 번 — id 가 있으면 PUT, 그 저장본이 다른 곳에서 바뀌었거나 없으면(MAIL_008 · RES_001) 지금 내용을 POST 로 새로 남긴다.
|
||||
* silent 는 첫 요청(PUT · POST)과 새로 남기는 POST 각각의 알림 끄기다
|
||||
*/
|
||||
async function putOrCreate(
|
||||
mail: DraftApi,
|
||||
payload: DraftPayload,
|
||||
id: string | null,
|
||||
silent: { first: MailRequestOptions['silent']; fallback: MailRequestOptions['silent'] },
|
||||
): Promise<{ saved: DraftSaved; replaced: boolean }> {
|
||||
if (id === null) return { saved: await mail.createDraft(payload, { silent: silent.first }), replaced: false }
|
||||
try {
|
||||
return { saved: await mail.updateDraft(id, payload, { silent: silent.first }), replaced: false }
|
||||
} catch (error) {
|
||||
if (!REPLACED_CODES.includes(toApiError(error).code)) throw error
|
||||
// 옛 저장본이 다른 곳에서 바뀌었거나 지워졌다(또는 임시보관함 밖으로 옮겨졌다) — 지금 내용을 새 저장본으로 남긴다
|
||||
return { saved: await mail.createDraft(payload, { silent: silent.fallback }), replaced: true }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 양식 없이 한 번 조용히 저장한다 — 보내는 중에 화면을 떠났고 발송이 실패했을 때 보내려던 내용을 남긴다.
|
||||
* 저장 규칙은 자동 저장과 같다 (putOrCreate). 알리지 않는다 — 실패하면 null
|
||||
*/
|
||||
export async function saveDraftDetached(payload: DraftPayload, draftId: string | null): Promise<DraftSaved | null> {
|
||||
try {
|
||||
return (await putOrCreate(useMail(), payload, draftId, { first: true, fallback: true })).saved
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
export function useDraftSaving(options: DraftSavingOptions) {
|
||||
const mail = useMail()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 마지막으로 저장한 저장본 id — 다음 저장은 이 id 로 PUT, 보내기는 draftId 로 */
|
||||
const draftId = ref<string | null>(null)
|
||||
const status = ref<DraftSaveStatus>('idle')
|
||||
/** 마지막으로 저장한 시각 — 상태 글에 보인다 */
|
||||
const savedAt = ref<string | null>(null)
|
||||
/** 마지막 저장의 실패 (성공하면 비운다) */
|
||||
const lastError = ref<ApiError | null>(null)
|
||||
|
||||
/** 양식 세대 — reset 마다 올린다. 늦게 끝난 저장이 새 양식을 건드리지 않게 */
|
||||
let generation = 0
|
||||
let inFlight: Promise<boolean> | null = null
|
||||
let idleTimer: ClockTimer | null = null
|
||||
/** 쉬지 않고 쓰는 동안의 저장 — 마지막 저장을 시작한 뒤 처음 바꾼 때 건다 (그 뒤로는 다시 걸지 않는다) */
|
||||
let maxWaitTimer: ClockTimer | null = null
|
||||
let retryTimer: ClockTimer | null = null
|
||||
/** 마지막 저장을 시작한 시각 — 자동 저장은 10초에 한 번까지 */
|
||||
let lastStartedAt = Number.NEGATIVE_INFINITY
|
||||
let retryCount = 0
|
||||
/** 저장하는 동안 자동 저장할 차례가 왔다 — 끝나면 다시 저장한다 */
|
||||
let queued = false
|
||||
let suspended = false
|
||||
let stopped = false
|
||||
|
||||
function clearIdle() {
|
||||
if (idleTimer !== null) clock.clearTimeout(idleTimer)
|
||||
idleTimer = null
|
||||
}
|
||||
|
||||
function clearMaxWait() {
|
||||
if (maxWaitTimer !== null) clock.clearTimeout(maxWaitTimer)
|
||||
maxWaitTimer = null
|
||||
}
|
||||
|
||||
function clearRetry() {
|
||||
if (retryTimer !== null) clock.clearTimeout(retryTimer)
|
||||
retryTimer = null
|
||||
}
|
||||
|
||||
/** 자동 저장 차례 — 10초 안에 이미 저장을 시작했으면 그때까지 미룬다 (입력 멈춤 · 30초 · 다시 해 보기가 모두 여기로 온다) */
|
||||
function autosave() {
|
||||
clearIdle()
|
||||
if (suspended || stopped) return
|
||||
if (inFlight) {
|
||||
queued = true
|
||||
return
|
||||
}
|
||||
if (!options.hasChanges()) return
|
||||
const wait = lastStartedAt + DRAFT_MIN_SAVE_INTERVAL_MS - clock.now()
|
||||
if (wait > 0) {
|
||||
idleTimer = clock.setTimeout(autosave, wait)
|
||||
return
|
||||
}
|
||||
void run('auto')
|
||||
}
|
||||
|
||||
function onMaxWait() {
|
||||
maxWaitTimer = null
|
||||
autosave()
|
||||
}
|
||||
|
||||
/**
|
||||
* 내용이 바뀌었다 — 입력이 3초 멈추면 저장한다 (계속 쓰는 동안은 미룬다).
|
||||
* 다만 바꾼 내용이 처음 생긴 뒤 30초가 지나면 멈춤을 기다리지 않는다 — 그 타이머는 처음 바꾼 때 한 번만 건다.
|
||||
* 바꾼 것이 없어졌으면(되돌림) 둘 다 거둔다.
|
||||
*/
|
||||
function touch() {
|
||||
if (suspended || stopped) return
|
||||
clearIdle()
|
||||
if (!options.hasChanges()) {
|
||||
clearMaxWait()
|
||||
return
|
||||
}
|
||||
idleTimer = clock.setTimeout(autosave, DRAFT_IDLE_SAVE_MS)
|
||||
if (maxWaitTimer === null) maxWaitTimer = clock.setTimeout(onMaxWait, DRAFT_MAX_WAIT_MS)
|
||||
}
|
||||
|
||||
function scheduleRetry() {
|
||||
clearRetry()
|
||||
const delay = DRAFT_RETRY_DELAYS_MS[Math.min(retryCount, DRAFT_RETRY_DELAYS_MS.length - 1)] ?? 0
|
||||
retryCount += 1
|
||||
retryTimer = clock.setTimeout(() => {
|
||||
retryTimer = null
|
||||
autosave()
|
||||
}, delay)
|
||||
}
|
||||
|
||||
/** 저장 요청 — PUT 이 MAIL_008 · RES_001 이면 새 저장본으로. 「임시 저장」만 사전 문구 알림을 띄운다 */
|
||||
function send(payload: DraftPayload, trigger: DraftSaveTrigger): Promise<{ saved: DraftSaved; replaced: boolean }> {
|
||||
const manual = trigger === 'manual'
|
||||
return putOrCreate(mail, payload, draftId.value, {
|
||||
first: manual ? REPLACED_CODES : true,
|
||||
fallback: manual ? undefined : true,
|
||||
})
|
||||
}
|
||||
|
||||
/** 저장 한 번 — 끝나면 성공 여부 */
|
||||
async function run(trigger: DraftSaveTrigger): Promise<boolean> {
|
||||
if (stopped || !options.canSave()) return false
|
||||
const request = options.prepare()
|
||||
if (!request) return false
|
||||
clearIdle()
|
||||
clearMaxWait()
|
||||
clearRetry()
|
||||
queued = false
|
||||
if ('rejected' in request) {
|
||||
// 보내도 서버가 거절할 내용이다 — 요청 없이 실패로 둔다. 저절로 다시 하지 않는다 (내용이 바뀌면 touch 가 다시 잡는다)
|
||||
status.value = 'failed'
|
||||
lastError.value = request.rejected
|
||||
options.onFailed?.(request.rejected, trigger)
|
||||
return false
|
||||
}
|
||||
const started = generation
|
||||
lastStartedAt = clock.now()
|
||||
status.value = 'saving'
|
||||
|
||||
// 이 저장이 바꿀 저장본 — send 가 이 id 로 PUT 한다(없으면 POST)
|
||||
const previousId = draftId.value
|
||||
const attempt = (async (): Promise<boolean> => {
|
||||
try {
|
||||
const { saved, replaced } = await send(request.payload, trigger)
|
||||
options.onSaved?.(previousId, saved)
|
||||
if (started !== generation) return false
|
||||
draftId.value = saved.id
|
||||
savedAt.value = saved.updatedAt
|
||||
status.value = replaced ? 'replaced' : 'saved'
|
||||
lastError.value = null
|
||||
retryCount = 0
|
||||
request.commit(saved)
|
||||
return true
|
||||
} catch (error) {
|
||||
if (started !== generation) return false
|
||||
const apiError = toApiError(error)
|
||||
status.value = 'failed'
|
||||
lastError.value = apiError
|
||||
options.onFailed?.(apiError, trigger)
|
||||
if (isRetryable(apiError) && !suspended && !stopped) scheduleRetry()
|
||||
return false
|
||||
}
|
||||
})()
|
||||
inFlight = attempt
|
||||
const ok = await attempt
|
||||
if (inFlight === attempt) inFlight = null
|
||||
// 저장하는 동안 자동 저장 차례가 왔다 — 남은 변경을 다시 (10초 간격은 autosave 가 지킨다)
|
||||
if (started === generation && queued) {
|
||||
queued = false
|
||||
autosave()
|
||||
}
|
||||
return ok
|
||||
}
|
||||
|
||||
/** 진행 중인 저장이 끝나기를 기다린다 (보내기 · 버리기 전) */
|
||||
async function settle(): Promise<void> {
|
||||
while (inFlight) await inFlight
|
||||
}
|
||||
|
||||
/**
|
||||
* 「임시 저장」 — 진행 중인 저장을 기다린 뒤, 바꾼 것이 있거나 아직 한 번도 저장하지 않았으면 저장한다.
|
||||
* 이미 저장한 그대로면 요청하지 않는다. 실패하면 사전 문구 알림(인터셉터)이 함께 뜬다.
|
||||
*/
|
||||
async function saveNow(): Promise<boolean> {
|
||||
clearIdle()
|
||||
await settle()
|
||||
if (draftId.value !== null && !options.hasChanges()) return true
|
||||
return run('manual')
|
||||
}
|
||||
|
||||
/** 떠나기 전 — 남은 변경을 저장한다. 저장한 그대로면(또는 저장에 성공하면) true */
|
||||
async function saveBeforeLeave(): Promise<boolean> {
|
||||
clearIdle()
|
||||
await settle()
|
||||
if (!options.hasChanges()) return true
|
||||
return run('leave')
|
||||
}
|
||||
|
||||
/** 자동 저장을 멈춘다 — 보내기 · 버리기 동안 */
|
||||
function suspend() {
|
||||
suspended = true
|
||||
clearIdle()
|
||||
clearMaxWait()
|
||||
clearRetry()
|
||||
}
|
||||
|
||||
/** 다시 자동 저장한다 — 보내기가 실패했을 때 등 */
|
||||
function resume() {
|
||||
if (stopped) return
|
||||
suspended = false
|
||||
touch()
|
||||
}
|
||||
|
||||
/** 더 저장하지 않는다 — 보냈거나 버렸다 */
|
||||
function stop() {
|
||||
stopped = true
|
||||
suspend()
|
||||
}
|
||||
|
||||
/** 양식을 새로 채웠다 — 이어 쓰는 저장본이면 그 id 와 저장 시각으로 시작한다 */
|
||||
function reset(existing: ExistingDraft | null = null) {
|
||||
generation += 1
|
||||
clearIdle()
|
||||
clearMaxWait()
|
||||
clearRetry()
|
||||
inFlight = null
|
||||
queued = false
|
||||
suspended = false
|
||||
stopped = false
|
||||
lastStartedAt = Number.NEGATIVE_INFINITY
|
||||
retryCount = 0
|
||||
draftId.value = existing && !existing.keepOriginal ? existing.id : null
|
||||
savedAt.value = existing?.savedAt ?? null
|
||||
status.value = existing ? 'saved' : 'idle'
|
||||
lastError.value = null
|
||||
}
|
||||
|
||||
// 화면이 사라지면 더 저장하지 않는다 — 진행 중인 저장은 끝까지 받아 draftId 를 맞춘다
|
||||
// (보내기를 누르고 바로 떠난 경우 — 보내기가 그 저장을 기다렸다가 새 draftId 로 보낸다)
|
||||
onScopeDispose(stop)
|
||||
|
||||
return {
|
||||
draftId: readonly(draftId),
|
||||
status: readonly(status),
|
||||
savedAt: readonly(savedAt),
|
||||
lastError: readonly(lastError),
|
||||
touch,
|
||||
saveNow,
|
||||
saveBeforeLeave,
|
||||
settle,
|
||||
suspend,
|
||||
resume,
|
||||
stop,
|
||||
reset,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { getErrorMessage, notifyApiError, toApiError } from './useApi'
|
||||
import { useMail } from './useMail'
|
||||
import { useToast } from './useToast'
|
||||
import { DRAFT_DISCARDED_MESSAGE, DRAFT_RESTORED_MESSAGE, DRAFT_UNDO_LABEL } from '@/shared/constants/mail'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
// 쓰기 화면의 휴지통 · 되돌리기 (디자인 README "2단계 › 임시 저장" · 계약 2절 "임시 저장")
|
||||
// - 저장본을 휴지통으로 옮긴다 — DELETE 가 아니라 PATCH { mailboxId: 휴지통 } 이다. DELETE 는 이미 휴지통에 있는 메일을 영구 삭제한다 —
|
||||
// 다른 창에서 먼저 휴지통으로 옮긴 저장본이 되돌릴 수 없게 지워지지 않게.
|
||||
// - 휴지통 id 를 모르면 메일함을 먼저 불러온다. 그래도 모르면 지우지 않고 실패다 — 메일함 목록을 못 받았으면 그 오류,
|
||||
// 받았는데 휴지통이 없으면 BIZ_001 을 알린다.
|
||||
// - 옮겼으면 알림 "임시 저장한 메일을 휴지통으로 옮겼습니다." + [되돌리기](임시보관함으로 — "임시보관함으로 되돌렸습니다.").
|
||||
// 이미 없는 저장본(RES_001 — 다른 곳에서 보냈거나 지웠다)이면 버린 것과 같다 — 알리지 않는다.
|
||||
|
||||
/** moved — 옮겼다 · gone — 이미 없다(RES_001) · failed — 옮기지 못했다(알렸다) */
|
||||
export type DraftTrashResult = 'moved' | 'gone' | 'failed'
|
||||
|
||||
export function useDraftTrash() {
|
||||
const mailStore = useMailStore()
|
||||
const { updateMessage } = useMail()
|
||||
const toast = useToast()
|
||||
|
||||
async function moveToTrash(draftId: string): Promise<DraftTrashResult> {
|
||||
let trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
// 메일함을 아직 못 받았다(받는 중 · 실패) — 보이는 패널은 그대로 둔 채 받아 본다 (스토어는 알리지 않는다)
|
||||
const loadError = await mailStore.loadMailboxes({ background: true })
|
||||
trash = mailStore.mailboxByRole('trash')
|
||||
if (!trash) {
|
||||
notifyApiError(loadError ?? { code: 'BIZ_001', message: getErrorMessage('BIZ_001') })
|
||||
return 'failed'
|
||||
}
|
||||
}
|
||||
try {
|
||||
await updateMessage(draftId, { mailboxId: trash.id }, { silent: ['RES_001'] })
|
||||
return 'moved'
|
||||
} catch (error) {
|
||||
// 그 밖의 실패는 인터셉터가 알렸다
|
||||
return toApiError(error).code === 'RES_001' ? 'gone' : 'failed'
|
||||
}
|
||||
}
|
||||
|
||||
/** 되돌리기 — 버린 저장본을 임시보관함으로 (PATCH { mailboxId }) */
|
||||
async function restore(draftId: string, draftsMailboxId: string) {
|
||||
if (await mailStore.moveMessage(draftId, draftsMailboxId)) toast.success(DRAFT_RESTORED_MESSAGE)
|
||||
}
|
||||
|
||||
/** 저장본을 휴지통으로 옮기고 알린다 — 옮겼으면 [되돌리기] 알림. 옮겼거나 이미 없으면 개수를 서버 기준으로 다시 맞춘다 */
|
||||
async function discard(draftId: string): Promise<DraftTrashResult> {
|
||||
const result = await moveToTrash(draftId)
|
||||
if (result === 'failed') return result
|
||||
// 임시보관함 · 휴지통 개수를 서버 기준으로
|
||||
mailStore.syncCounts()
|
||||
if (result === 'moved') {
|
||||
const draftsMailbox = mailStore.mailboxByRole('drafts')
|
||||
toast.success(
|
||||
DRAFT_DISCARDED_MESSAGE,
|
||||
draftsMailbox ? { label: DRAFT_UNDO_LABEL, run: () => void restore(draftId, draftsMailbox.id) } : undefined,
|
||||
)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
return { discard }
|
||||
}
|
||||
@@ -1,11 +1,13 @@
|
||||
import { onBeforeUnmount, readonly, ref, watch } from 'vue'
|
||||
import { computed, onBeforeUnmount, readonly, ref, watch } from 'vue'
|
||||
import { onBeforeRouteLeave, onBeforeRouteUpdate, type RouteLocationNormalized } from 'vue-router'
|
||||
|
||||
// 화면을 떠나기 전 확인 — 쓰던 내용을 잃는 이동을 묻는다 (디자인 README "나가기 전 확인")
|
||||
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 취소 · 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
|
||||
// - 앱 안 이동(링크 · 앱 레일 · 뒤로/앞으로 · 화면의 닫기): 이동 가드가 답을 기다린다. 화면은 confirming 동안 대화상자를 띄운다.
|
||||
// 남기로 하면 이동을 취소한다 — 뒤로/앞으로였으면 라우터가 주소와 방문 기록을 되돌린다.
|
||||
// 묻기 전에 prepareLeave(임시 저장 등)를 기다린다 — 그 뒤에도 잃을 것이 남았을 때만 묻는다.
|
||||
// - 창 닫기 · 새로고침 · 다른 사이트로 가기: 지킬 것이 있는 동안만 beforeunload 를 걸어 브라우저 기본 확인창을 띄운다.
|
||||
// 화면 컴포넌트의 setup 에서 부른다 — 이동 가드(onBeforeRouteLeave · onBeforeRouteUpdate)는 RouterView 가 그린 화면에 걸린다.
|
||||
// guarding: 이동 가드가 준비 · 답을 기다리는 동안 true — 그동안 화면이 스스로 주소를 바꾸면(router.replace) 그 이동을 취소해 버린다.
|
||||
|
||||
export interface LeaveConfirmOptions {
|
||||
/**
|
||||
@@ -24,6 +26,16 @@ export interface LeaveConfirmOptions {
|
||||
* 주지 않으면 같은 경로 안의 이동은 묻지 않는다.
|
||||
*/
|
||||
leavesOnUpdate?: (to: RouteLocationNormalized, from: RouteLocationNormalized) => boolean
|
||||
/**
|
||||
* 묻기 전에 할 일 — 예: 남은 변경을 임시 저장한다. 끝난 뒤 active 를 다시 읽어 잃을 것이 남았을 때만 묻는다.
|
||||
* 이동은 이것이 끝날 때까지 기다린다 (그사이 다른 이동이 오면 라우터가 앞 이동을 취소한다)
|
||||
*/
|
||||
prepareLeave?: () => Promise<unknown>
|
||||
/**
|
||||
* 묻는 중에 잃을 것이 없어졌을 때(active 가 false) 그대로 떠날지 — 예: 늦게 끝난 떠나기 전 저장(이제 잃을 것이 없다).
|
||||
* 주지 않거나 false 면 그 이동을 취소한다 — 세션이 끝난 때처럼 다른 흐름(로그인 화면으로)이 이어받을 때
|
||||
*/
|
||||
leaveWhenSettled?: () => boolean
|
||||
}
|
||||
|
||||
export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
@@ -52,8 +64,34 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
resolve?.(leave)
|
||||
}
|
||||
|
||||
function confirmLeave(): true | Promise<boolean> {
|
||||
return options.active() ? ask() : true
|
||||
/** 떠나기 전 준비 — 이동이 겹쳐도 한 번만 한다 */
|
||||
let preparing: Promise<unknown> | null = null
|
||||
function prepare(): Promise<unknown> {
|
||||
if (!options.prepareLeave) return Promise.resolve()
|
||||
if (!preparing) {
|
||||
// 준비가 실패해도 이동 가드를 깨뜨리지 않는다 — 그다음 active 로 물을지 정한다
|
||||
preparing = options
|
||||
.prepareLeave()
|
||||
.catch(() => undefined)
|
||||
.finally(() => {
|
||||
preparing = null
|
||||
})
|
||||
}
|
||||
return preparing
|
||||
}
|
||||
|
||||
/** 답을 기다리는 이동 가드 수 — 이동이 겹치면 여럿이다 */
|
||||
const guards = ref<number>(0)
|
||||
|
||||
async function confirmLeave(): Promise<boolean> {
|
||||
if (!options.active()) return true
|
||||
guards.value += 1
|
||||
try {
|
||||
await prepare()
|
||||
return options.active() ? await ask() : true
|
||||
} finally {
|
||||
guards.value -= 1
|
||||
}
|
||||
}
|
||||
|
||||
onBeforeRouteLeave(() => confirmLeave())
|
||||
@@ -78,7 +116,7 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
watch(options.unloadActive ?? options.active, listenBeforeUnload, { immediate: true })
|
||||
|
||||
watch(options.active, (active) => {
|
||||
if (!active && pending) answer(false)
|
||||
if (!active && pending) answer(options.leaveWhenSettled?.() ?? false)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
@@ -89,6 +127,8 @@ export function useLeaveConfirm(options: LeaveConfirmOptions) {
|
||||
|
||||
return {
|
||||
confirming: readonly(confirming),
|
||||
/** 이동 가드가 준비(prepareLeave) · 답을 기다리는 중 */
|
||||
guarding: computed(() => guards.value > 0),
|
||||
/** 계속 쓰기 — 이동을 취소한다 */
|
||||
stay: () => answer(false),
|
||||
/** 나가기 — 이동을 그대로 보낸다 */
|
||||
|
||||
@@ -8,13 +8,21 @@ import {
|
||||
} from '@/shared/constants/mail'
|
||||
import type {
|
||||
Attachment,
|
||||
Draft,
|
||||
DraftPayload,
|
||||
DraftSaved,
|
||||
MailQuota,
|
||||
MailSignature,
|
||||
Mailbox,
|
||||
MessageDetail,
|
||||
MessageListQuery,
|
||||
MessageSummary,
|
||||
RecipientSuggestion,
|
||||
RecipientSuggestionQuery,
|
||||
SaveSignaturePayload,
|
||||
SendMessagePayload,
|
||||
SendMessageResult,
|
||||
SignatureImage,
|
||||
UpdateMessagePayload,
|
||||
} from '@/shared/types/mail'
|
||||
import type { PaginatedResult } from '@/shared/types/pagination'
|
||||
@@ -86,13 +94,71 @@ export function useMail() {
|
||||
return api.get<MessageDetail, MessageDetail>(`/messages/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
function updateMessage(id: string, changes: UpdateMessagePayload): Promise<MessageSummary> {
|
||||
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes)
|
||||
function updateMessage(
|
||||
id: string,
|
||||
changes: UpdateMessagePayload,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<MessageSummary> {
|
||||
return api.patch<MessageSummary, MessageSummary>(`/messages/${segment(id)}`, changes, options)
|
||||
}
|
||||
|
||||
/** 휴지통이 아니면 휴지통으로 옮기고, 휴지통이면 영구 삭제한다 (204) */
|
||||
async function deleteMessage(id: string): Promise<void> {
|
||||
await api.delete(`/messages/${segment(id)}`)
|
||||
async function deleteMessage(id: string, options?: MailRequestOptions): Promise<void> {
|
||||
await api.delete(`/messages/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
// ── 임시 저장 (계약 2절 "임시 저장") ─────────────────────
|
||||
|
||||
/** 이어 쓸 임시 저장본 — 없거나 초안이 아니면 404 RES_001 */
|
||||
function getDraft(id: string, options?: MailRequestOptions): Promise<Draft> {
|
||||
return api.get<Draft, Draft>(`/drafts/${segment(id)}`, options)
|
||||
}
|
||||
|
||||
/** 처음 저장 — 201 DraftSaved. 본문 · 첨부는 발송과 같은 한도라 시간 한도도 발송과 같다 */
|
||||
function createDraft(payload: DraftPayload, options?: MailRequestOptions): Promise<DraftSaved> {
|
||||
return api.post<DraftSaved, DraftSaved>('/drafts', payload, { timeout: MAIL_SEND_TIMEOUT_MS, ...options })
|
||||
}
|
||||
|
||||
/**
|
||||
* 다시 저장 — 서버가 새 초안을 만들고 옛것을 지운다(응답의 id · attachments 로 바꿔 쓴다).
|
||||
* 옛 초안이 다른 곳에서 먼저 바뀌었거나 지워졌으면 409 MAIL_008 — 부르는 쪽이 createDraft 로 새로 남긴다.
|
||||
*/
|
||||
function updateDraft(id: string, payload: DraftPayload, options?: MailRequestOptions): Promise<DraftSaved> {
|
||||
return api.put<DraftSaved, DraftSaved>(`/drafts/${segment(id)}`, payload, {
|
||||
timeout: MAIL_SEND_TIMEOUT_MS,
|
||||
...options,
|
||||
})
|
||||
}
|
||||
|
||||
// ── 서명 (계약 2절 "서명") ─────────────────────────────
|
||||
|
||||
/** 저장한 적 없으면 기본값(둘 다 넣기 · 빈 서명)이 온다 */
|
||||
function getSignature(options?: MailRequestOptions): Promise<MailSignature> {
|
||||
return api.get<MailSignature, MailSignature>('/mail-signature', options)
|
||||
}
|
||||
|
||||
function saveSignature(payload: SaveSignaturePayload, options?: MailRequestOptions): Promise<MailSignature> {
|
||||
return api.put<MailSignature, MailSignature>('/mail-signature', payload, options)
|
||||
}
|
||||
|
||||
/** 서명 그림 한 장 (multipart `file`) — PNG · JPEG · GIF, 300KB 이하, 한 사람 3장까지 */
|
||||
function uploadSignatureImage(file: File, options?: MailRequestOptions): Promise<SignatureImage> {
|
||||
const form = new FormData()
|
||||
form.append('file', file)
|
||||
return api.post<SignatureImage, SignatureImage>('/mail-signature/images', form, options)
|
||||
}
|
||||
|
||||
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ──────────────
|
||||
|
||||
/** 사내 → 최근 주고받은 주소 순. q 가 있으면 이름 · 주소에 그 글자가 든 것만 */
|
||||
function listRecipientSuggestions(
|
||||
query: RecipientSuggestionQuery,
|
||||
options?: MailRequestOptions,
|
||||
): Promise<PaginatedResult<RecipientSuggestion>> {
|
||||
return api.get<PaginatedResult<RecipientSuggestion>, PaginatedResult<RecipientSuggestion>>(
|
||||
'/recipient-suggestions',
|
||||
{ ...options, params: query },
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -160,5 +226,12 @@ export function useMail() {
|
||||
uploadAttachment,
|
||||
attachmentDownloadUrl,
|
||||
downloadAttachment,
|
||||
getDraft,
|
||||
createDraft,
|
||||
updateDraft,
|
||||
getSignature,
|
||||
saveSignature,
|
||||
uploadSignatureImage,
|
||||
listRecipientSuggestions,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,145 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { effectScope, type EffectScope } from 'vue'
|
||||
|
||||
import {
|
||||
RECIPIENT_SEARCH_DELAY_MS,
|
||||
useRecipientSuggestions,
|
||||
} from '@/composables/useRecipientSuggestions'
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { createManualClock, type ManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockRequest,
|
||||
} from '@/test-utils/mock-api'
|
||||
|
||||
const DOYUN: RecipientSuggestion = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }
|
||||
const JUNHO: RecipientSuggestion = { name: '박준호', email: 'junho.park@arasystem.it.kr', source: 'directory' }
|
||||
const VENDOR: RecipientSuggestion = { name: '협력사 담당', email: 'ops@vendor.example', source: 'recent' }
|
||||
|
||||
function page(items: RecipientSuggestion[], hasNext: boolean) {
|
||||
return ok({ items, pageInfo: { hasNext, nextCursor: hasNext ? 'next' : null, total: null } })
|
||||
}
|
||||
|
||||
describe('useRecipientSuggestions (받는 사람 추천 — 계약 2절)', () => {
|
||||
let scope: EffectScope
|
||||
let clock: ManualClock
|
||||
let requests: MockRequest[]
|
||||
let excluded: Set<string>
|
||||
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated({ email: 'ara.kim@arasystem.it.kr', name: '김아라' })
|
||||
scope = effectScope()
|
||||
clock = createManualClock()
|
||||
excluded = new Set()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
scope.stop()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
function setup(complete: boolean, searchResult: RecipientSuggestion[] = []) {
|
||||
requests = installMockApi((request) =>
|
||||
request.params.q === undefined ? page([DOYUN, JUNHO], !complete) : page(searchResult, false),
|
||||
)
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
if (!handle) throw new Error('scope')
|
||||
return handle
|
||||
}
|
||||
|
||||
const searches = () => requests.filter((request) => request.params.q !== undefined).map((request) => request.params.q)
|
||||
|
||||
it('쓰기 화면을 열 때 q 없이 100개를 한 번 받아 두고 화면에서 거른다', async () => {
|
||||
const handle = setup(true)
|
||||
handle.preload()
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.map((request) => request.params)).toEqual([{ limit: 100 }])
|
||||
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
|
||||
|
||||
excluded = new Set([DOYUN.email])
|
||||
expect(handle.matchesFor('ㄷㅇ')).toEqual([])
|
||||
})
|
||||
|
||||
it('받아 둔 목록이 전부면 서버에 다시 묻지 않는다', async () => {
|
||||
const handle = setup(true)
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
handle.search('vendor')
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual([])
|
||||
})
|
||||
|
||||
it('모자라면 250ms 기다린 뒤 마지막 글자로 묻고, 받은 것을 더한다 — 초성은 묻지 않는다', async () => {
|
||||
const handle = setup(false, [VENDOR])
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
handle.search('ven')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
|
||||
handle.search('vendor')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS - 1)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual([])
|
||||
clock.advance(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(searches()).toEqual(['vendor'])
|
||||
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
|
||||
|
||||
handle.search('ㄷㅇ')
|
||||
handle.search('vendor')
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual(['vendor'])
|
||||
})
|
||||
|
||||
it('미리 받기가 실패하면 치는 대로 q 로 묻고(자모만 친 글자는 묻지 않는다), 다음 preload(칸에 초점)가 다시 받는다', async () => {
|
||||
let failPreload = true
|
||||
requests = installMockApi((request) => {
|
||||
if (request.params.q !== undefined) return page([VENDOR], false)
|
||||
if (failPreload) return fail(503, 'MAIL_001')
|
||||
return page([DOYUN, JUNHO], false)
|
||||
})
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
if (!handle) throw new Error('scope')
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
|
||||
handle.search('ㄷㅇ')
|
||||
clock.advance(1_000)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual([])
|
||||
handle.search('vendor')
|
||||
clock.advance(RECIPIENT_SEARCH_DELAY_MS)
|
||||
await flushPromises()
|
||||
expect(searches()).toEqual(['vendor'])
|
||||
expect(handle.matchesFor('vendor').map((match) => match.suggestion)).toEqual([VENDOR])
|
||||
|
||||
failPreload = false
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
handle.preload()
|
||||
await flushPromises()
|
||||
expect(requests.filter((request) => request.params.q === undefined)).toHaveLength(2)
|
||||
expect(handle.matchesFor('ㄷㅇ').map((match) => match.suggestion)).toEqual([DOYUN])
|
||||
})
|
||||
|
||||
it('추천을 못 받아도 조용히 넘어간다 (주소를 그대로 칠 수 있다)', async () => {
|
||||
installMockApi(() => fail(503, 'MAIL_001'))
|
||||
const handle = scope.run(() => useRecipientSuggestions({ excluded: () => excluded, clock }))
|
||||
handle?.preload()
|
||||
await flushPromises()
|
||||
expect(handle?.matchesFor('도윤')).toEqual([])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,127 @@
|
||||
import { onScopeDispose, ref, shallowRef, type InjectionKey } from 'vue'
|
||||
|
||||
import { systemClock, type Clock, type ClockTimer } from './useClock'
|
||||
import { useMail } from './useMail'
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
import {
|
||||
RECIPIENT_SUGGESTION_LIMIT,
|
||||
filterRecipientSuggestions,
|
||||
hasHangulJamo,
|
||||
type RecipientMatch,
|
||||
} from '@/shared/utils/recipient-match'
|
||||
|
||||
// 받는 사람 추천 (계약 2절 "받는 사람 추천" · 디자인 README "2단계 › 받는 사람 자동 완성")
|
||||
// - 쓰기 화면을 열 때 q 없이 limit=100 으로 한 번 받아 두고, 치는 대로 화면에서 거른다(초성 포함 — recipient-match.ts)
|
||||
// - 맞는 것이 8개보다 모자라고 받아 둔 목록이 전부가 아닐 때(hasNext)만 250ms 기다린 뒤 q 로 서버에 다시 묻는다.
|
||||
// 받은 것은 받아 둔 목록에 더한다. 서버는 초성을 모르므로 초성 · 홀로 쓴 자모가 든 글자는 묻지 않는다
|
||||
// - 추천은 보조 정보다 — 못 받아도 알리지 않는다 (silent). 주소는 그대로 칠 수 있다.
|
||||
// 미리 받기가 실패하면 치는 대로 q 로 묻고(초성 · 자모만 친 글자는 여전히 묻지 않는다), 다음에 칸에 초점이 올 때 다시 받아 본다
|
||||
// 쓰기 화면이 만들어 provide 하고(RECIPIENT_SUGGESTIONS), 받는 사람 칸(RecipientField)이 inject 해 쓴다.
|
||||
|
||||
/** 처음 받아 두는 개수 — 계약의 최대 limit */
|
||||
export const RECIPIENT_SUGGESTION_PRELOAD = 100
|
||||
/** 서버에 다시 묻기 전에 기다리는 시간 */
|
||||
export const RECIPIENT_SEARCH_DELAY_MS = 250
|
||||
/** 서버가 받는 검색 글자 길이 (계약: q 1~100자) */
|
||||
const SEARCH_QUERY_MAX_LENGTH = 100
|
||||
|
||||
export interface RecipientSuggestionsOptions {
|
||||
/** 이미 넣은 주소 — 받는 사람 · 참조 · 숨은참조 모두 (정규화한 주소) */
|
||||
excluded: () => ReadonlySet<string>
|
||||
clock?: Clock
|
||||
}
|
||||
|
||||
export interface RecipientSuggestionsHandle {
|
||||
/** 쓰기 화면을 열 때 · 받는 사람 칸에 초점이 올 때 — 받는 중이거나 받았으면 아무것도 하지 않는다 (실패했으면 다시 받는다) */
|
||||
preload: () => void
|
||||
/** 친 글자에 맞는 추천 — 최대 8개, 반응형으로 읽는다 (받아 둔 목록 · 이미 넣은 주소가 바뀌면 다시 계산된다) */
|
||||
matchesFor: (query: string) => RecipientMatch[]
|
||||
/** 맞는 것이 모자라면 서버에 묻는다 (기다렸다가 — 그사이 또 치면 마지막 글자만) */
|
||||
search: (query: string) => void
|
||||
}
|
||||
|
||||
export const RECIPIENT_SUGGESTIONS: InjectionKey<RecipientSuggestionsHandle> = Symbol('recipient-suggestions')
|
||||
|
||||
export function useRecipientSuggestions(options: RecipientSuggestionsOptions): RecipientSuggestionsHandle {
|
||||
const { listRecipientSuggestions } = useMail()
|
||||
const clock = options.clock ?? systemClock
|
||||
|
||||
/** 받아 둔 추천 — 처음 받은 것 + 검색으로 더 받은 것 (주소가 겹치지 않게) */
|
||||
const pool = shallowRef<readonly RecipientSuggestion[]>([])
|
||||
/** 처음 받은 목록이 전부다 — 서버에 더 물을 것이 없다 */
|
||||
const complete = ref<boolean>(false)
|
||||
/** 미리 받기 — 실패했으면 다음 preload 가 다시 받는다 */
|
||||
let preloadState: 'idle' | 'loading' | 'done' | 'failed' = 'idle'
|
||||
let searchTimer: ClockTimer | null = null
|
||||
let controller: AbortController | null = null
|
||||
/** 이미 물어본 글자 — 같은 글자를 두 번 묻지 않는다 */
|
||||
const asked = new Set<string>()
|
||||
|
||||
function merge(items: readonly RecipientSuggestion[]) {
|
||||
const known = new Set(pool.value.map((item) => normalizeEmail(item.email)))
|
||||
const added = items.filter((item) => {
|
||||
const key = normalizeEmail(item.email)
|
||||
if (known.has(key)) return false
|
||||
known.add(key)
|
||||
return true
|
||||
})
|
||||
if (added.length > 0) pool.value = [...pool.value, ...added]
|
||||
}
|
||||
|
||||
function preload() {
|
||||
if (preloadState === 'loading' || preloadState === 'done') return
|
||||
preloadState = 'loading'
|
||||
void listRecipientSuggestions({ limit: RECIPIENT_SUGGESTION_PRELOAD }, { silent: true })
|
||||
.then((page) => {
|
||||
preloadState = 'done'
|
||||
merge(page.items)
|
||||
complete.value = !page.pageInfo.hasNext
|
||||
})
|
||||
.catch(() => {
|
||||
// 치는 대로 서버에 묻는다(search) — 다음에 칸에 초점이 오면 다시 받아 본다
|
||||
preloadState = 'failed'
|
||||
})
|
||||
}
|
||||
|
||||
function matchesFor(query: string): RecipientMatch[] {
|
||||
return filterRecipientSuggestions(query, pool.value, options.excluded())
|
||||
}
|
||||
|
||||
function cancelSearch() {
|
||||
if (searchTimer !== null) clock.clearTimeout(searchTimer)
|
||||
searchTimer = null
|
||||
controller?.abort()
|
||||
controller = null
|
||||
}
|
||||
|
||||
async function ask(query: string) {
|
||||
searchTimer = null
|
||||
asked.add(query)
|
||||
controller = new AbortController()
|
||||
const { signal } = controller
|
||||
try {
|
||||
const page = await listRecipientSuggestions(
|
||||
{ q: query, limit: RECIPIENT_SUGGESTION_LIMIT * 2 },
|
||||
{ silent: true, signal },
|
||||
)
|
||||
if (!signal.aborted) merge(page.items)
|
||||
} catch {
|
||||
// 보조 정보 — 못 받으면 받아 둔 목록만 보인다
|
||||
asked.delete(query)
|
||||
}
|
||||
}
|
||||
|
||||
function search(query: string) {
|
||||
cancelSearch()
|
||||
const text = query.trim()
|
||||
if (text === '' || text.length > SEARCH_QUERY_MAX_LENGTH || complete.value) return
|
||||
if (hasHangulJamo(text) || asked.has(text)) return
|
||||
if (matchesFor(text).length >= RECIPIENT_SUGGESTION_LIMIT) return
|
||||
searchTimer = clock.setTimeout(() => void ask(text), RECIPIENT_SEARCH_DELAY_MS)
|
||||
}
|
||||
|
||||
onScopeDispose(cancelSearch)
|
||||
|
||||
return { preload, matchesFor, search }
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
import { defineComponent, h, type FunctionalComponent } from 'vue'
|
||||
|
||||
import SessionExpiredDialog from '@/components/feedback/SessionExpiredDialog.vue'
|
||||
import { CLOCK } from '@/composables/useClock'
|
||||
import {
|
||||
AUTH_CHANNEL_NAME,
|
||||
closeLogoutChannel,
|
||||
@@ -21,6 +22,7 @@ import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { FakeBroadcastChannel } from '@/test-utils/broadcast'
|
||||
import { createManualClock } from '@/test-utils/manual-clock'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
@@ -100,6 +102,14 @@ describe('ComposePage', () => {
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/**
|
||||
* 화면에 주는 것 — 지금 경로(이동 가드) · 손으로 돌리는 시계.
|
||||
* 시계를 넘기지 않으면 자동 저장(입력이 3초 멈추면)이 돌지 않는다 — 이 파일은 자동 저장을 보지 않는다 (ComposePage.drafts.spec.ts 가 본다)
|
||||
*/
|
||||
function screenProvide(router: ReturnType<typeof createTestRouter>): Record<symbol, unknown> {
|
||||
return { ...routeViewProvide(router), [CLOCK as symbol]: createManualClock() }
|
||||
}
|
||||
|
||||
/**
|
||||
* attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다.
|
||||
* 화면은 RouterView 없이 띄운다 — 다른 경로로 가도 화면이 남아 결과를 볼 수 있다. 이동 가드는 지금 경로에 건다 (routeViewProvide)
|
||||
@@ -113,7 +123,7 @@ describe('ComposePage', () => {
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, {
|
||||
attachTo: attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
|
||||
global: { plugins: [pinia, router], provide: screenProvide(router) },
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
@@ -906,8 +916,24 @@ describe('ComposePage', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('나가기 전 확인 (디자인 README "나가기 전 확인")', () => {
|
||||
describe('나가기 전 확인 (디자인 README "2단계 › 임시 저장 › 나가기 확인")', () => {
|
||||
const DIALOG = '#compose-leave-dialog'
|
||||
/**
|
||||
* 임시 저장이 늘 실패하는 서버 — 떠나기 전에 저장하지 못해 묻는다.
|
||||
* (저장이 되면 묻지 않고 떠난다 — 그 흐름은 ComposePage.drafts.spec.ts 가 본다)
|
||||
*/
|
||||
const unsavable =
|
||||
(base: MockHandler): MockHandler =>
|
||||
(request) =>
|
||||
request.url.startsWith('/drafts') ? fail(503, 'MAIL_001') : base(request)
|
||||
/** 임시 저장을 받는 서버 — 처음은 POST 로 d1 */
|
||||
const savable =
|
||||
(base: MockHandler): MockHandler =>
|
||||
(request) =>
|
||||
request.method === 'POST' && request.url === '/drafts'
|
||||
? ok({ id: 'd1', attachments: [], updatedAt: '2026-10-27T06:12:00Z' }, 201)
|
||||
: base(request)
|
||||
const draftSaves = (requests: MockRequest[]) => requests.filter((request) => request.url.startsWith('/drafts'))
|
||||
const CHECKLIST = { id: 'att1', name: '체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
const forwardApi: MockHandler = (request) =>
|
||||
request.method === 'GET' && request.url === '/messages/m1'
|
||||
@@ -969,20 +995,28 @@ describe('ComposePage', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('손대지 않은 전달 초안(원본 첨부 포함)도 묻지 않는다 — 원본 첨부를 빼면 바꾼 것이다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?forward=m1', forwardApi)
|
||||
it('손대지 않은 전달 초안(원본 첨부 포함)은 저장하지도 묻지도 않는다 — 원본 첨부를 빼면 바꾼 것이라 떠나기 전에 저장한다', async () => {
|
||||
const first = await mountCompose('/mail/compose?forward=m1', savable(forwardApi))
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await first.router.push('/mail/inbox')
|
||||
expect(draftSaves(first.requests)).toEqual([])
|
||||
first.wrapper.unmount()
|
||||
|
||||
const { wrapper, router, requests } = await mountCompose('/mail/compose?forward=m1', savable(forwardApi))
|
||||
await wrapper.get('button[aria-label="체크리스트.xlsx 첨부 취소"]').trigger('click')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
void router.push('/mail/inbox')
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
expect(draftSaves(requests).map((request) => [request.method, request.url])).toEqual([['POST', '/drafts']])
|
||||
expect(draftSaves(requests)[0]?.data).toMatchObject({ subject: 'Fwd: 메일 서버 이전 일정 공유', attachments: [] })
|
||||
})
|
||||
|
||||
it('바꾸면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, true)
|
||||
it('저장하지 못하면 앱 안 이동 전에 묻는다 — alertdialog · 처음 초점은 계속 쓰기, 계속 쓰기는 이동을 취소하고 초점을 돌려준다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi), true)
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
const subjectInput = wrapper.get('#compose-subject').element as HTMLInputElement
|
||||
subjectInput.focus()
|
||||
@@ -995,8 +1029,8 @@ describe('ComposePage', () => {
|
||||
expect(dialog.attributes('role')).toBe('alertdialog')
|
||||
expect(dialog.attributes('aria-labelledby')).toBe('compose-leave-title')
|
||||
expect(dialog.attributes('aria-describedby')).toBe('compose-leave-message')
|
||||
expect(wrapper.get('#compose-leave-title').text()).toBe('보내지 않은 메일이 있습니다')
|
||||
expect(wrapper.get('#compose-leave-message').text()).toBe('이 화면을 나가면 쓰던 내용이 사라집니다.')
|
||||
expect(wrapper.get('#compose-leave-title').text()).toBe('저장하지 못한 내용이 있습니다')
|
||||
expect(wrapper.get('#compose-leave-message').text()).toBe('이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.')
|
||||
expect(wrapper.get('#compose-leave-stay').text()).toBe('계속 쓰기')
|
||||
expect(wrapper.get('#compose-leave-confirm').text()).toBe('나가기')
|
||||
expect(document.activeElement?.id).toBe('compose-leave-stay')
|
||||
@@ -1013,13 +1047,32 @@ describe('ComposePage', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('쓰기 화면의 닫기 ✕ · 취소도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
|
||||
it('「취소」는 없다 — 닫기 ✕ 는 저장한 채 닫는다', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
const { wrapper, router, requests } = await mountCompose('/mail/compose', savable(replyApi))
|
||||
expect(wrapper.find('.compose__cancel').exists()).toBe(false)
|
||||
expect(wrapper.findAll('.compose__footer a, .compose__footer > button').map((item) => item.text())).toEqual([
|
||||
'보내기',
|
||||
'임시 저장',
|
||||
])
|
||||
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.compose__cancel').trigger('click')
|
||||
await wrapper.get('.compose__close').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox/m1')
|
||||
expect(draftSaves(requests)[0]?.data).toMatchObject({ body: '쓰던 본문' })
|
||||
})
|
||||
|
||||
it('저장하지 못하면 닫기 ✕ 도 묻는다 — 나가기를 고르면 쓰던 내용을 버리고 보던 화면으로 간다', async () => {
|
||||
useMailStore().rememberBrowseLocation('/mail/inbox/m1')
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
|
||||
editorOf(wrapper.element).commands.insertContent('쓰던 본문')
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.get('.compose__close').trigger('click')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
await wrapper.get('#compose-leave-stay').trigger('click')
|
||||
@@ -1037,7 +1090,7 @@ describe('ComposePage', () => {
|
||||
})
|
||||
|
||||
it('Esc 는 계속 쓰기와 같다 — 브라우저가 닫지 않게 막고 이동을 취소한다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
const navigation = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
@@ -1053,7 +1106,7 @@ describe('ComposePage', () => {
|
||||
})
|
||||
|
||||
it('뒤로 가기를 취소하면 주소와 방문 기록이 그대로다 — 다시 뒤로 가서 나가기를 고르면 앞 화면으로', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi, false, ['/mail/inbox'])
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi), false, ['/mail/inbox'])
|
||||
await writeSubject(wrapper, '초안')
|
||||
|
||||
router.back()
|
||||
@@ -1076,7 +1129,7 @@ describe('ComposePage', () => {
|
||||
})
|
||||
|
||||
it('묻는 중에 또 이동하면 대화상자는 하나다 — 나가기를 고르면 마지막 이동으로 간다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
|
||||
await writeSubject(wrapper, '일정 공유')
|
||||
|
||||
const first = router.push('/mail/inbox')
|
||||
@@ -1093,8 +1146,8 @@ describe('ComposePage', () => {
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
})
|
||||
|
||||
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 묻는다 — 계속 쓰면 답장 그대로', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', replyApi)
|
||||
it('같은 쓰기 화면 안에서도 쓰기 종류가 바뀌면(답장 → 새 메일) 저장부터 하고, 못 하면 묻는다 — 계속 쓰면 답장 그대로', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose?replyTo=m1', unsavable(replyApi))
|
||||
await writeSubject(wrapper, 'Re: 고친 제목')
|
||||
|
||||
const navigation = router.push('/mail/compose')
|
||||
@@ -1348,7 +1401,7 @@ describe('ComposePage', () => {
|
||||
})
|
||||
|
||||
it('묻는 중에 세션이 만료되면 이 대화상자를 닫고 그 이동은 취소한다 — 세션 만료 대화상자와 겹치지 않는다', async () => {
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', replyApi)
|
||||
const { wrapper, router } = await mountCompose('/mail/compose', unsavable(replyApi))
|
||||
const sessionDialog = mount(SessionExpiredDialog, { global: { plugins: [pinia, router] } })
|
||||
mountedWrappers.push(sessionDialog)
|
||||
await writeSubject(wrapper, '쓰던 메일')
|
||||
@@ -1388,7 +1441,7 @@ describe('ComposePage', () => {
|
||||
const router = createTestRouter()
|
||||
await router.push('/mail/compose?forward=m1')
|
||||
await router.isReady()
|
||||
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: routeViewProvide(router) } })
|
||||
const wrapper = mount(ComposePage, { global: { plugins: [pinia, router], provide: screenProvide(router) } })
|
||||
mountedWrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -3,6 +3,7 @@
|
||||
// 패널은 하위 화면이 바뀌어도 다시 그리지 않는다.
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
import MailboxPanel from '@/components/mail/MailboxPanel.vue'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
const mailStore = useMailStore()
|
||||
@@ -11,6 +12,8 @@ const mailStore = useMailStore()
|
||||
void mailStore.ensureMailboxes()
|
||||
// 패널 하단 사용량과 쓰기 화면의 첨부 한도(attachmentMaxBytes)
|
||||
void mailStore.ensureQuota()
|
||||
// 쓰기 화면이 열 때 넣을 서명 — 미리 받아 두면 편집기가 기다리지 않는다 (못 받으면 서명 없이 연다)
|
||||
void useMailSignatureStore().ensureSignature()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -0,0 +1,460 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MailSignature, SaveSignaturePayload, SignatureImage } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import {
|
||||
fail,
|
||||
flushPromises,
|
||||
installMockApi,
|
||||
ok,
|
||||
restoreApiAdapter,
|
||||
type MockHandler,
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { editorOf, installProseMirrorDom } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter, routeViewProvide } from '@/test-utils/router'
|
||||
import MailSignaturePage from './MailSignaturePage.vue'
|
||||
|
||||
// 설정 › 메일 › 서명 (디자인 원본 Settings · 계약 2절 "서명")
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 서명 칸(지연 로딩한 TipTap 청크)을 기다리는 시간 — 쓰기 화면 테스트와 같은 값 */
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 15_000
|
||||
const IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
|
||||
const NEW_IMAGE_ID = '00000000-0000-4000-8000-000000000001'
|
||||
const SAVED_AT = '2026-10-27T06:00:00Z'
|
||||
|
||||
const SIGNATURE: MailSignature = {
|
||||
bodyHtml: '<div style="font-size: 14px"><p style="margin: 0"><strong>김아라</strong> · 인프라팀</p></div>',
|
||||
body: '김아라 · 인프라팀',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: '2026-10-27T05:00:00Z',
|
||||
}
|
||||
|
||||
function imageOf(id: string, width: number): SignatureImage {
|
||||
return { id, type: 'image/png', size: 1000, width, height: 60 }
|
||||
}
|
||||
|
||||
interface Server {
|
||||
signature?: MailSignature | MockResult
|
||||
/** 그림 올리기 응답 — 비면 NEW_IMAGE_ID · 폭 480 */
|
||||
uploads?: MockResult[]
|
||||
save?: (request: MockRequest) => MockResult | Promise<MockResult>
|
||||
}
|
||||
|
||||
function server(options: Server = {}): MockHandler {
|
||||
const uploads = [...(options.uploads ?? [])]
|
||||
return (request) => {
|
||||
const { method, url } = request
|
||||
if (method === 'GET' && url === '/mail-signature') {
|
||||
const signature = options.signature ?? SIGNATURE
|
||||
return typeof signature === 'string' || 'status' in signature ? signature : ok(signature)
|
||||
}
|
||||
if (method === 'POST' && url === '/mail-signature/images') {
|
||||
return uploads.shift() ?? ok(imageOf(NEW_IMAGE_ID, 480), 201)
|
||||
}
|
||||
if (method === 'PUT' && url === '/mail-signature') {
|
||||
if (options.save) return options.save(request)
|
||||
const body = request.data as SaveSignaturePayload
|
||||
return ok({ ...body, images: [], updatedAt: SAVED_AT })
|
||||
}
|
||||
return ok(null)
|
||||
}
|
||||
}
|
||||
|
||||
describe('MailSignaturePage', () => {
|
||||
let pinia: Pinia
|
||||
const mounted: VueWrapper[] = []
|
||||
|
||||
beforeAll(installProseMirrorDom)
|
||||
beforeAll(async () => {
|
||||
await import('@/components/mail/editor/signature/SignatureForm.vue')
|
||||
}, EDITOR_LOAD_TIMEOUT_MS)
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mounted.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountPage(handler: MockHandler, waitForEditor = true) {
|
||||
const requests = installMockApi(handler)
|
||||
const router = createTestRouter()
|
||||
await router.push('/settings/mail/signature')
|
||||
await router.isReady()
|
||||
// 서명 칸의 나가기 확인(onBeforeRouteLeave)이 지금 경로에 걸리게 — RouterView 가 주는 값과 같다
|
||||
const wrapper = mount(MailSignaturePage, {
|
||||
attachTo: document.body,
|
||||
global: { plugins: [pinia, router], provide: routeViewProvide(router) },
|
||||
})
|
||||
mounted.push(wrapper)
|
||||
await flushPromises()
|
||||
if (waitForEditor) {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
return { wrapper, requests, router }
|
||||
}
|
||||
|
||||
function choose(wrapper: VueWrapper, files: File[]) {
|
||||
const picker = wrapper.get('#signature-image-input')
|
||||
Object.defineProperty(picker.element, 'files', { value: files, configurable: true })
|
||||
return picker.trigger('change')
|
||||
}
|
||||
|
||||
const png = (name = '로고.png', size = 1000) => new File([new Uint8Array(size)], name, { type: 'image/png' })
|
||||
|
||||
function saveRequest(requests: MockRequest[]) {
|
||||
return requests.find((request) => request.method === 'PUT' && request.url === '/mail-signature')
|
||||
}
|
||||
|
||||
it('저장한 서명으로 칸을 채우고, 넣을 메일 · 미리 보기를 보인다', async () => {
|
||||
const { wrapper } = await mountPage(server())
|
||||
|
||||
expect(wrapper.get('h1').text()).toBe('서명')
|
||||
expect(wrapper.get('#signature-body').text()).toBe('김아라 · 인프라팀')
|
||||
expect(wrapper.get('#signature-body').attributes('aria-labelledby')).toBe('signature-label')
|
||||
expect(wrapper.get('[role="toolbar"]').attributes('aria-label')).toBe('서명 서식')
|
||||
expect(wrapper.findAll('[role="toolbar"] button').map((button) => button.attributes('aria-label'))).toEqual([
|
||||
'굵게',
|
||||
'기울임',
|
||||
'밑줄',
|
||||
'글자 색: 기본',
|
||||
'링크',
|
||||
'그림 넣기',
|
||||
])
|
||||
expect((wrapper.get('#signature-for-new').element as HTMLInputElement).checked).toBe(true)
|
||||
expect((wrapper.get('#signature-for-reply').element as HTMLInputElement).checked).toBe(true)
|
||||
expect(wrapper.get('[data-signature-preview]').text()).toBe('김아라 · 인프라팀')
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('새 메일은 본문 끝에 서명을 넣습니다.')
|
||||
})
|
||||
|
||||
it('받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도"', async () => {
|
||||
let fails = 1
|
||||
const { wrapper, requests } = await mountPage(
|
||||
(request) => {
|
||||
if (request.url === '/mail-signature' && fails > 0) {
|
||||
fails -= 1
|
||||
return fail(503, 'MAIL_001')
|
||||
}
|
||||
return server()(request)
|
||||
},
|
||||
false,
|
||||
)
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain(ERROR_CODE_MESSAGES.MAIL_001)
|
||||
|
||||
await wrapper.get('[role="alert"] button').trigger('click')
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!wrapper.find('#signature-body').exists()) throw new Error('서명 칸을 불러오는 중')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
expect(requests.filter((request) => request.url === '/mail-signature')).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('[저장] — 쓰기 본문과 같은 서식 본문 · 텍스트 서명 · 넣을 메일을 보내고 "저장했습니다"를 보인다 (바꾸면 내린다)', async () => {
|
||||
const { wrapper, requests } = await mountPage(server())
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.insertContentAt(editor.state.doc.content.size, '<p>ARA SYSTEM</p>')
|
||||
await wrapper.get('#signature-for-reply').setValue(false)
|
||||
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveRequest(requests)?.data).toEqual({
|
||||
bodyHtml: expect.stringMatching(/^<div style="font-family: [^"]+; font-size: 14px; line-height: 1\.75">/),
|
||||
body: '김아라 · 인프라팀\nARA SYSTEM',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
})
|
||||
expect(wrapper.get('#signature-save-status').text()).toBe('저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.')
|
||||
expect(useMailSignatureStore().signature?.isForReply).toBe(false)
|
||||
|
||||
await wrapper.get('#signature-for-new').setValue(false)
|
||||
expect(wrapper.get('#signature-save-status').text()).toBe('')
|
||||
})
|
||||
|
||||
it('비운 서명은 bodyHtml · body 모두 null 로 저장한다', async () => {
|
||||
const { wrapper, requests } = await mountPage(server())
|
||||
editorOf(wrapper.element).commands.clearContent(true)
|
||||
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(saveRequest(requests)?.data).toEqual({ bodyHtml: null, body: null, isForNew: true, isForReply: true })
|
||||
})
|
||||
|
||||
it('서버가 서명을 거절하면(VAL_001) 알림 없이 서명 칸 아래에 사전 문구', async () => {
|
||||
const { wrapper } = await mountPage(server({ save: () => fail(400, 'VAL_001') }))
|
||||
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#signature-body').attributes('aria-describedby')).toBe('signature-hint signature-error')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
describe('서명 그림', () => {
|
||||
it('그림을 올려 보통(144) 폭으로 넣고 고른다 — 크기 줄: 작게 96 · 보통 144 · 원본(600 이하) · 그림 빼기', async () => {
|
||||
const { wrapper, requests } = await mountPage(server({ uploads: [ok(imageOf(NEW_IMAGE_ID, 800), 201)] }))
|
||||
await choose(wrapper, [png()])
|
||||
await flushPromises()
|
||||
|
||||
const upload = requests.find((request) => request.url === '/mail-signature/images')
|
||||
expect(upload?.data).toBeInstanceOf(FormData)
|
||||
const image = () => wrapper.get('#signature-body img[src]')
|
||||
expect(image().attributes()).toMatchObject({ src: `/api/mail-signature/images/${NEW_IMAGE_ID}`, width: '144' })
|
||||
expect(wrapper.get('[aria-labelledby="signature-image-size-label"]').attributes('role')).toBe('group')
|
||||
const pressed = () => wrapper.get('[data-size][aria-pressed="true"]').text()
|
||||
expect(pressed()).toBe('보통')
|
||||
|
||||
await wrapper.get('[data-size="small"]').trigger('click')
|
||||
expect(image().attributes('width')).toBe('96')
|
||||
expect(pressed()).toBe('작게')
|
||||
await wrapper.get('[data-size="original"]').trigger('click')
|
||||
expect(image().attributes('width')).toBe('600')
|
||||
expect(pressed()).toBe('원본')
|
||||
|
||||
await wrapper.get('.signature-editor__remove').trigger('click')
|
||||
expect(wrapper.find('#signature-body img[src]').exists()).toBe(false)
|
||||
expect(wrapper.find('[data-size]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('작은 그림은 늘리지 않는다 — 원래 폭으로 넣는다', async () => {
|
||||
const { wrapper } = await mountPage(server({ uploads: [ok(imageOf(NEW_IMAGE_ID, 80), 201)] }))
|
||||
await choose(wrapper, [png()])
|
||||
await flushPromises()
|
||||
expect(wrapper.get('#signature-body img[src]').attributes('width')).toBe('80')
|
||||
})
|
||||
|
||||
it.each([
|
||||
['PNG · JPG · GIF 가 아니면 FILE_001', new File(['x'], '로고.webp', { type: 'image/webp' }), 'FILE_001'],
|
||||
['확장자와 형식이 다르면 FILE_001', new File(['x'], '로고.gif', { type: 'image/png' }), 'FILE_001'],
|
||||
['300KB 를 넘으면 FILE_002', png('큰 로고.png', 300 * 1024 + 1), 'FILE_002'],
|
||||
] as const)('올리기 전에 막는다 — %s (서명 칸 아래에 사전 문구)', async (_label, file, code) => {
|
||||
const { wrapper, requests } = await mountPage(server())
|
||||
await choose(wrapper, [file])
|
||||
await flushPromises()
|
||||
|
||||
expect(requests.some((request) => request.url === '/mail-signature/images')).toBe(false)
|
||||
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES[code])
|
||||
})
|
||||
|
||||
it('3장까지 — 서명 칸에 있는 서로 다른 그림을 센다 (같은 그림 두 번은 한 장, 뺀 그림은 세지 않는다)', async () => {
|
||||
const SECOND_ID = '00000000-0000-4000-8000-000000000002'
|
||||
const imageTag = (id: string) => `<img src="/api/mail-signature/images/${id}" width="96" alt="">`
|
||||
const withTwo: MailSignature = {
|
||||
...SIGNATURE,
|
||||
bodyHtml: `<p>${imageTag(IMAGE_ID)}${imageTag(IMAGE_ID)}${imageTag(SECOND_ID)}</p>`,
|
||||
images: [imageOf(IMAGE_ID, 200), imageOf(SECOND_ID, 200)],
|
||||
}
|
||||
const { wrapper, requests } = await mountPage(
|
||||
server({
|
||||
signature: withTwo,
|
||||
uploads: [
|
||||
ok(imageOf('00000000-0000-4000-8000-000000000003', 200), 201),
|
||||
ok(imageOf('00000000-0000-4000-8000-000000000004', 200), 201),
|
||||
],
|
||||
}),
|
||||
)
|
||||
const uploads = () => requests.filter((request) => request.url === '/mail-signature/images')
|
||||
|
||||
// 두 장이 있다 — 한 장만 더 올리고 넷째 장은 올리지 않고 막는다
|
||||
await choose(wrapper, [png('a.png'), png('b.png')])
|
||||
await flushPromises()
|
||||
expect(uploads()).toHaveLength(1)
|
||||
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
// 서명 칸에서 그림을 빼면 다시 넣을 수 있다 — 올려 두기만 한 그림은 세지 않는다 (그것은 서버의 10장 한도)
|
||||
const editor = editorOf(wrapper.element)
|
||||
let secondAt = -1
|
||||
editor.state.doc.descendants((node, pos) => {
|
||||
if (node.attrs.src === `/api/mail-signature/images/${SECOND_ID}`) secondAt = pos
|
||||
})
|
||||
editor.commands.setNodeSelection(secondAt)
|
||||
await flushPromises()
|
||||
await wrapper.get('.signature-editor__remove').trigger('click')
|
||||
|
||||
await choose(wrapper, [png('c.png')])
|
||||
await flushPromises()
|
||||
expect(uploads()).toHaveLength(2)
|
||||
expect(wrapper.find('#signature-error').exists()).toBe(false)
|
||||
expect(wrapper.findAll('#signature-body img[src]')).toHaveLength(4)
|
||||
})
|
||||
|
||||
it('서버가 그림을 거절하면(FILE_001 등) 알림 없이 서명 칸 아래에 사전 문구', async () => {
|
||||
const { wrapper } = await mountPage(server({ uploads: [fail(400, 'FILE_001')] }))
|
||||
await choose(wrapper, [png()])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('#signature-error').text()).toBe(ERROR_CODE_MESSAGES.FILE_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('올려 둔 그림이 10장을 넘었다는 거절(VAL_001)은 인터셉터의 알림으로 — 서명 칸 아래 문구는 형식 · 크기 거절만', async () => {
|
||||
const { wrapper } = await mountPage(server({ uploads: [fail(400, 'VAL_001')] }))
|
||||
await choose(wrapper, [png()])
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('#signature-error').exists()).toBe(false)
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([ERROR_CODE_MESSAGES.VAL_001])
|
||||
expect(wrapper.find('#signature-body img[src]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('나가기 확인 (디자인 README "2단계 › 서명")', () => {
|
||||
/** 창 닫기 · 새로고침 때 브라우저 확인창을 띄우려 하는가 */
|
||||
function blocksUnload(): boolean {
|
||||
const event = new Event('beforeunload', { cancelable: true })
|
||||
window.dispatchEvent(event)
|
||||
return event.defaultPrevented
|
||||
}
|
||||
|
||||
function dialogOpen(wrapper: VueWrapper): boolean {
|
||||
return wrapper.get('#signature-leave-dialog').attributes('open') !== undefined
|
||||
}
|
||||
|
||||
/** 서명 끝에 한 줄을 더 쓴다 */
|
||||
function typeLine(wrapper: VueWrapper, text: string) {
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.insertContentAt(editor.state.doc.content.size, `<p>${text}</p>`)
|
||||
return flushPromises()
|
||||
}
|
||||
|
||||
it('바꾸고 떠나면 묻는다 — alertdialog · 처음 초점은 계속 고치기, [계속 고치기]는 머물고 [나가기]는 떠난다', async () => {
|
||||
const { wrapper, router } = await mountPage(server())
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
const stay = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
|
||||
const dialog = wrapper.get('#signature-leave-dialog')
|
||||
expect(dialogOpen(wrapper)).toBe(true)
|
||||
expect(dialog.attributes('role')).toBe('alertdialog')
|
||||
expect(dialog.attributes('aria-labelledby')).toBe('signature-leave-title')
|
||||
expect(dialog.attributes('aria-describedby')).toBe('signature-leave-message')
|
||||
expect(wrapper.get('#signature-leave-title').text()).toBe('저장하지 않은 서명이 있습니다')
|
||||
expect(wrapper.get('#signature-leave-message').text()).toBe('이 화면을 나가면 바꾼 서명이 사라집니다.')
|
||||
expect(wrapper.get('#signature-leave-stay').text()).toBe('계속 고치기')
|
||||
expect(wrapper.get('#signature-leave-confirm').text()).toBe('나가기')
|
||||
expect(document.activeElement?.id).toBe('signature-leave-stay')
|
||||
|
||||
await wrapper.get('#signature-leave-stay').trigger('click')
|
||||
await stay
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/settings/mail/signature')
|
||||
expect(wrapper.get('#signature-body').text()).toContain('ARA SYSTEM')
|
||||
|
||||
const leave = router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
await wrapper.get('#signature-leave-confirm').trigger('click')
|
||||
await leave
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('불러온(저장한 뒤에는 저장한) 서명과 같으면 묻지 않는다 — 바꿨다 되돌린 것 · 저장한 뒤, 창 닫기 확인도 그동안만', async () => {
|
||||
const { wrapper, router } = await mountPage(server())
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 넣을 메일을 바꿨다 되돌리면 바꾼 것이 아니다
|
||||
await wrapper.get('#signature-for-reply').setValue(false)
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await wrapper.get('#signature-for-reply').setValue(true)
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
// 쓰고 저장하면 저장한 서명이 새 기준이다
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
await wrapper.get('#signature-for-new').setValue(false)
|
||||
expect(blocksUnload()).toBe(true)
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
})
|
||||
|
||||
it('저장하는 중에는 앱 안 이동을 묻지 않는다 — 창 닫기는 저장이 끝날 때까지 확인한다', async () => {
|
||||
let finishSave: (() => void) | null = null
|
||||
const { wrapper, router } = await mountPage(
|
||||
server({
|
||||
save: (request) =>
|
||||
new Promise<MockResult>((resolve) => {
|
||||
finishSave = () => resolve(ok({ ...(request.data as SaveSignaturePayload), images: [], updatedAt: SAVED_AT }))
|
||||
}),
|
||||
}),
|
||||
)
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
await wrapper.get('#signature-save').trigger('click')
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
await router.push('/mail/inbox')
|
||||
await flushPromises()
|
||||
expect(dialogOpen(wrapper)).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/mail/inbox')
|
||||
|
||||
;(finishSave as (() => void) | null)?.()
|
||||
await flushPromises()
|
||||
expect(blocksUnload()).toBe(false)
|
||||
expect(useMailSignatureStore().signature?.body).toBe('김아라 · 인프라팀\nARA SYSTEM')
|
||||
})
|
||||
|
||||
it('세션이 끝나면 묻지 않는다 — 서명 칸을 내리고 창 닫기 확인도 뗀다', async () => {
|
||||
const { wrapper, router } = await mountPage(server())
|
||||
await typeLine(wrapper, 'ARA SYSTEM')
|
||||
expect(blocksUnload()).toBe(true)
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await flushPromises()
|
||||
|
||||
expect(blocksUnload()).toBe(false)
|
||||
await router.push('/login')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#signature-leave-dialog').exists()).toBe(false)
|
||||
expect(router.currentRoute.value.fullPath).toBe('/login')
|
||||
})
|
||||
})
|
||||
|
||||
it('미리 보기 — [답장]은 인용 위에 서명, 넣지 않기로 했으면 빼고 안내가 바뀐다', async () => {
|
||||
const { wrapper } = await mountPage(server())
|
||||
const replyMode = wrapper.get('[data-mode="reply"]')
|
||||
await replyMode.trigger('click')
|
||||
|
||||
expect(replyMode.attributes('aria-pressed')).toBe('true')
|
||||
expect(wrapper.find('.signature-preview__quote').exists()).toBe(true)
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달은 인용한 원문 위에 서명을 넣습니다.')
|
||||
|
||||
await wrapper.get('#signature-for-reply').setValue(false)
|
||||
expect(wrapper.get('[data-signature-preview]').isVisible()).toBe(false)
|
||||
expect(wrapper.get('[data-signature-preview-note]').text()).toBe('답장 · 전달에는 서명을 넣지 않습니다.')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,122 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 › 메일 › 서명 (디자인 원본 Settings · README "2단계 › 서명") — /settings/mail/signature
|
||||
// 들어올 때마다 서명을 새로 받는다(다른 창에서 고쳤을 수 있다). 받는 동안은 자리만, 못 받으면 사전 문구와 "다시 시도".
|
||||
// 서명 칸(TipTap)은 지연 로딩한다 — 못 받으면 쓰기 화면과 같은 "다시 시도" (ComposeEditorLoadError · MAIL_EDITOR_RETRY).
|
||||
import { defineAsyncComponent, provide, ref } from 'vue'
|
||||
|
||||
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
|
||||
import MailLoadError from '@/components/mail/MailLoadError.vue'
|
||||
import { MAIL_EDITOR_RETRY } from '@/components/mail/editor/mail-editor-handle'
|
||||
import { SIGNATURE_TEXT } from '@/shared/constants/mail-signature'
|
||||
import { useMailSignatureStore } from '@/stores/mail-signature.store'
|
||||
|
||||
/** 서명 칸 — TipTap 을 이 화면에서만 내려받는다 */
|
||||
const SignatureForm = defineAsyncComponent({
|
||||
loader: () => import('@/components/mail/editor/signature/SignatureForm.vue'),
|
||||
errorComponent: ComposeEditorLoadError,
|
||||
})
|
||||
|
||||
const signatureStore = useMailSignatureStore()
|
||||
|
||||
/** 서명 칸을 새로 만든다 — 청크를 못 받았을 때 "다시 시도" */
|
||||
const formKey = ref<number>(0)
|
||||
|
||||
provide(MAIL_EDITOR_RETRY, () => {
|
||||
formKey.value += 1
|
||||
})
|
||||
|
||||
function reload() {
|
||||
void signatureStore.load()
|
||||
}
|
||||
|
||||
// 처음 그리기 전에 — 지난번에 받아 둔 서명으로 칸을 잠깐 그렸다 지우지 않게
|
||||
reload()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main
|
||||
class="signature-page"
|
||||
aria-labelledby="signature-page-title"
|
||||
>
|
||||
<div class="signature-page__column">
|
||||
<div class="signature-page__intro">
|
||||
<h1
|
||||
id="signature-page-title"
|
||||
class="signature-page__title"
|
||||
>
|
||||
{{ SIGNATURE_TEXT.title }}
|
||||
</h1>
|
||||
<p class="signature-page__description">
|
||||
{{ SIGNATURE_TEXT.description }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<MailLoadError
|
||||
v-if="signatureStore.status === 'error' && signatureStore.error"
|
||||
:error="signatureStore.error"
|
||||
leave-label=""
|
||||
@retry="reload"
|
||||
/>
|
||||
<SignatureForm
|
||||
v-else-if="signatureStore.status === 'ready' && signatureStore.signature"
|
||||
:key="formKey"
|
||||
:signature="signatureStore.signature"
|
||||
/>
|
||||
<div
|
||||
v-else
|
||||
class="signature-page__placeholder"
|
||||
aria-busy="true"
|
||||
/>
|
||||
</div>
|
||||
</main>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.signature-page {
|
||||
flex: 999 1 35rem;
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 콘텐츠 열 — 최대 800 · 좌우 32 · 위 20 · 아래 36 (읽기 · 쓰기와 같은 값) */
|
||||
.signature-page__column {
|
||||
max-width: var(--content-max-width);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-22);
|
||||
padding: var(--content-block-start) var(--content-inline) var(--content-block-end);
|
||||
}
|
||||
|
||||
.signature-page__intro {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
}
|
||||
|
||||
.signature-page__title {
|
||||
margin: 0;
|
||||
font-size: var(--font-size-title-lg);
|
||||
font-weight: 700;
|
||||
letter-spacing: var(--letter-spacing-tight);
|
||||
}
|
||||
|
||||
.signature-page__description {
|
||||
margin: 0;
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
|
||||
/* 서명을 받는 동안의 자리 — 서명 칸(툴바 52 + 편집 132)만큼 */
|
||||
.signature-page__placeholder {
|
||||
min-height: calc(var(--size-editor-toolbar) + 8.25rem);
|
||||
border-radius: var(--radius-lg);
|
||||
background: var(--color-border-subtle);
|
||||
}
|
||||
|
||||
/* 넓은 화면에서는 이 칸만 따로 스크롤한다 */
|
||||
@media (min-width: 70rem) {
|
||||
.signature-page {
|
||||
overflow-y: auto;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
// 설정 모듈 레이아웃 (디자인 원본 Settings) — 전역 셸 + 왼쪽 설정 메뉴(208 · #F8F9FB) + 하위 화면
|
||||
// 메뉴는 묶음("메일") 아래 항목("서명")으로 늘어난다. 지금은 메일 서명만 있다.
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import AppShell from '@/components/shell/AppShell.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<AppShell module-id="settings">
|
||||
<nav
|
||||
class="settings-menu"
|
||||
aria-label="설정 메뉴"
|
||||
>
|
||||
<p
|
||||
id="settings-menu-mail"
|
||||
class="settings-menu__group"
|
||||
>
|
||||
메일
|
||||
</p>
|
||||
<ul
|
||||
class="settings-menu__list"
|
||||
aria-labelledby="settings-menu-mail"
|
||||
>
|
||||
<li>
|
||||
<RouterLink
|
||||
:to="{ name: 'settings-mail-signature' }"
|
||||
class="settings-menu__link"
|
||||
active-class="is-active"
|
||||
>
|
||||
<AppIcon
|
||||
name="edit"
|
||||
:size="16"
|
||||
/>
|
||||
<span>서명</span>
|
||||
</RouterLink>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
<RouterView />
|
||||
</AppShell>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
/* 왼쪽 패널 — 메일함 패널과 같은 바탕 · 간격 */
|
||||
.settings-menu {
|
||||
flex: 1 1 13rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-4);
|
||||
padding: var(--space-14) var(--space-10);
|
||||
border-right: var(--border-width) solid var(--color-border);
|
||||
background: var(--color-surface-panel);
|
||||
}
|
||||
|
||||
.settings-menu__group {
|
||||
margin: var(--space-4) var(--space-10);
|
||||
font-size: var(--font-size-small);
|
||||
font-weight: 600;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
.settings-menu__list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-1);
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
}
|
||||
|
||||
.settings-menu__link {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-10);
|
||||
min-height: var(--size-row-min);
|
||||
padding: 0 var(--space-10);
|
||||
border-radius: var(--radius-item);
|
||||
color: var(--color-text-strong);
|
||||
font-weight: 500;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.settings-menu__link:hover {
|
||||
background: var(--color-surface-muted);
|
||||
color: var(--color-text);
|
||||
}
|
||||
|
||||
.settings-menu__link.is-active {
|
||||
background: var(--color-brand-subtle);
|
||||
color: var(--color-brand-strong);
|
||||
font-weight: 600;
|
||||
}
|
||||
</style>
|
||||
@@ -48,6 +48,21 @@ const routes: RouteRecordRaw[] = [
|
||||
{ path: ':mailboxId/:messageId', name: 'mail-message', component: MailboxPage },
|
||||
],
|
||||
},
|
||||
{
|
||||
// 설정 — 왼쪽 메뉴(메일 › 서명) + 하위 화면. /settings 는 첫 메뉴로 바꿔 끼운다 (앱 레일의 "설정")
|
||||
path: '/settings',
|
||||
component: () => import('@/pages/settings/SettingsLayout.vue'),
|
||||
meta: { requiresAuth: true, title: '설정' },
|
||||
children: [
|
||||
{ path: '', name: 'settings', redirect: { name: 'settings-mail-signature' } },
|
||||
{
|
||||
path: 'mail/signature',
|
||||
name: 'settings-mail-signature',
|
||||
component: () => import('@/pages/settings/MailSignaturePage.vue'),
|
||||
meta: { title: '메일 서명' },
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
path: '/:pathMatch(.*)*',
|
||||
name: 'not-found',
|
||||
|
||||
@@ -26,7 +26,7 @@ export const APP_MODULES: readonly AppModule[] = [
|
||||
{ id: 'files', label: '파일', icon: 'folder', routeName: null, placement: 'main' },
|
||||
{ id: 'erp', label: 'ERP', icon: 'briefcase', routeName: null, placement: 'main' },
|
||||
{ id: 'contacts', label: '주소록', icon: 'users', routeName: null, placement: 'main' },
|
||||
{ id: 'settings', label: '설정', icon: 'settings', routeName: null, placement: 'end' },
|
||||
{ id: 'settings', label: '설정', icon: 'settings', routeName: 'settings', placement: 'end' },
|
||||
]
|
||||
|
||||
/** 비활성 모듈에 붙이는 안내 (툴팁·스크린리더) */
|
||||
|
||||
@@ -117,6 +117,12 @@ export const ICONS = {
|
||||
forward: [polyline('15 14 20 9 15 4'), path('M4 20v-7a4 4 0 0 1 4-4h12')],
|
||||
shield: [path('M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z')],
|
||||
'alert-circle': [circle(12, 12, 10), ...ALERT_DOT],
|
||||
/** 안내 — 임시 저장 "다른 곳에서 바뀐 저장본" (디자인 원본 ComposeAssist) */
|
||||
info: [circle(12, 12, 10), line(12, 16, 12, 12), line(12, 8, 12.01, 8)],
|
||||
/** 진행 중 — 임시 저장 "저장하는 중…" (원의 3/4) */
|
||||
loader: [path('M21 12a9 9 0 1 1-6.22-8.56')],
|
||||
/** 그림 넣기 — 서명 칸 툴바 (디자인 원본 Settings) */
|
||||
image: [rect(3, 3, 18, 18, 2), circle(8.5, 8.5, 1.5), polyline('21 15 16 10 5 21')],
|
||||
x: [line(18, 6, 6, 18), line(6, 6, 18, 18)],
|
||||
download: [
|
||||
path('M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4'),
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
// 메일 서명 상수 — 계약 2절 "서명" · 디자인 README "2단계 › 서명" (디자인 원본 Settings)
|
||||
// 한도는 서버(backend/src/modules/mail-signature/mail-signature.constants.ts)와 같은 값이다 — 서버가 최종 검증한다.
|
||||
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { SignatureImageType } from '@/shared/types/mail'
|
||||
|
||||
/** 서명 그림 한 장 최대 크기 — 300KB (서버 SIGNATURE_IMAGE_MAX_BYTES = 300 × 1024, 넘으면 413 FILE_002) */
|
||||
export const SIGNATURE_IMAGE_MAX_BYTES = 300 * 1024
|
||||
/**
|
||||
* 서명에 넣을 수 있는 그림 수 — 서명 칸에 있는 서로 다른 그림으로 센다(뺀 그림은 세지 않는다).
|
||||
* 넘으면 서명 저장이 400 VAL_001 — 클라이언트가 그림을 넣기 전에 먼저 막는다.
|
||||
* (올려 두기만 한 그림까지 한 사람 10장은 서버가 따로 막는다 — 올리기가 400 VAL_001, 알림은 인터셉터)
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_MAX_COUNT = 3
|
||||
/** 서명 서식(HTML) 최대 길이 — 서버는 정화한 뒤 잰다(넘으면 400 VAL_001). 보내기 전에 같은 길이로 먼저 막는다 */
|
||||
export const SIGNATURE_HTML_MAX_LENGTH = 10_000
|
||||
/** 텍스트 서명 최대 길이 (JS 문자열 길이) */
|
||||
export const SIGNATURE_TEXT_MAX_LENGTH = 2_000
|
||||
|
||||
/** 받는 그림 형식 — 확장자와 MIME 이 이 짝이어야 한다 (서버는 파일 앞부분까지 본다. WebP · SVG 는 받지 않는다) */
|
||||
export const SIGNATURE_IMAGE_TYPES_BY_EXTENSION: Readonly<Record<string, SignatureImageType>> = {
|
||||
png: 'image/png',
|
||||
jpg: 'image/jpeg',
|
||||
jpeg: 'image/jpeg',
|
||||
gif: 'image/gif',
|
||||
}
|
||||
/** <input type="file" accept> 값 */
|
||||
export const SIGNATURE_IMAGE_ACCEPT = Object.keys(SIGNATURE_IMAGE_TYPES_BY_EXTENSION)
|
||||
.map((extension) => `.${extension}`)
|
||||
.join(',')
|
||||
|
||||
/**
|
||||
* 그림 크기 줄 (디자인 원본 Settings) — 작게 96 · 보통 144 · 원본(원래 폭, 600 이하). 새로 넣는 그림은 보통(원래 폭이 더 작으면 원래 폭).
|
||||
* 원본 상한은 서버가 남기는 width(1~600)와 같다 (outgoing-html-rules.ts 의 IMAGE_MAX_WIDTH).
|
||||
*/
|
||||
export const SIGNATURE_IMAGE_SIZES = {
|
||||
small: 96,
|
||||
medium: 144,
|
||||
} as const
|
||||
export type SignatureImageSize = keyof typeof SIGNATURE_IMAGE_SIZES | 'original'
|
||||
|
||||
/** 그림 올리기의 실패 중 서명 칸 아래에 직접 보이는 코드 (형식 · 크기) — 전역 알림은 끈다. 10장 초과(VAL_001)는 알림으로 */
|
||||
export const SIGNATURE_IMAGE_INLINE_CODES: readonly ErrorCode[] = ['FILE_001', 'FILE_002']
|
||||
/** 서명 저장의 실패 중 서명 칸 아래에 직접 보이는 코드 (길이 · 그림 3장 초과) — 전역 알림은 끈다 */
|
||||
export const SIGNATURE_SAVE_INLINE_CODES: readonly ErrorCode[] = ['VAL_001']
|
||||
|
||||
/** 서명을 바꾸고 저장하지 않은 채 떠날 때 (앱 안 이동은 대화상자, 탭 닫기 · 새로고침은 브라우저 확인창) */
|
||||
export const SIGNATURE_LEAVE_DIALOG = {
|
||||
title: '저장하지 않은 서명이 있습니다',
|
||||
message: '이 화면을 나가면 바꾼 서명이 사라집니다.',
|
||||
stay: '계속 고치기',
|
||||
leave: '나가기',
|
||||
} as const
|
||||
|
||||
/** 설정 화면 문구 (디자인 원본 Settings) */
|
||||
export const SIGNATURE_TEXT = {
|
||||
title: '서명',
|
||||
description:
|
||||
'보내는 메일 끝에 넣는 글입니다. 쓰기 화면을 열 때 본문에 넣어 두므로, 그 메일에서만 고치거나 지울 수도 있습니다.',
|
||||
fieldLabel: '서명 내용',
|
||||
hint:
|
||||
'굵게 · 기울임 · 밑줄 · 글자 색 · 링크 · 그림을 쓸 수 있습니다. 그림은 PNG · JPG · GIF, 한 장 300KB 이하, 3장까지이고 ' +
|
||||
'붙여 넣거나 끌어다 놓아도 됩니다. 메일 안에 함께 실어 보내므로 받는 쪽에서 외부 이미지처럼 막히지 않습니다.',
|
||||
usageLegend: '서명을 넣을 메일',
|
||||
forNew: '새 메일',
|
||||
forReply: '답장 · 전달',
|
||||
forReplyNote: '— 인용한 원문 위에 넣습니다',
|
||||
save: '저장',
|
||||
saving: '저장하는 중…',
|
||||
saved: '저장했습니다. 새로 여는 쓰기 화면부터 들어갑니다.',
|
||||
uploading: '그림을 올리는 중…',
|
||||
imageSizeLabel: '그림 크기',
|
||||
imageSizes: { small: '작게', medium: '보통', original: '원본' },
|
||||
removeImage: '그림 빼기',
|
||||
previewTitle: '미리 보기',
|
||||
previewSwitchLabel: '미리 볼 메일',
|
||||
previewNew: '새 메일',
|
||||
previewReply: '답장',
|
||||
previewBody: '(여기에 쓴 내용)',
|
||||
} as const
|
||||
|
||||
/** 미리 보기 아래 안내 (디자인 원본 Settings) */
|
||||
export const SIGNATURE_PREVIEW_NOTES = {
|
||||
newOn: '새 메일은 본문 끝에 서명을 넣습니다.',
|
||||
newOff: '새 메일에는 서명을 넣지 않습니다.',
|
||||
replyOn: '답장 · 전달은 인용한 원문 위에 서명을 넣습니다.',
|
||||
replyOff: '답장 · 전달에는 서명을 넣지 않습니다.',
|
||||
} as const
|
||||
|
||||
/**
|
||||
* 답장 미리 보기의 원문 인용 — 실제 메일이 아니라 자리만 보인다 (캔버스의 예시 이름 대신 괄호 글 — 2026-10-27 구현하며 정함).
|
||||
* 머리 줄 이름은 쓰기 화면의 원문 인용과 같다 (mail-quote.ts).
|
||||
*/
|
||||
export const SIGNATURE_PREVIEW_QUOTE = {
|
||||
rows: [
|
||||
{ label: '보낸 사람', value: '(보낸 사람)' },
|
||||
{ label: '날짜', value: '(보낸 날짜)' },
|
||||
{ label: '제목', value: '(원래 제목)' },
|
||||
],
|
||||
body: '(원래 메일 내용)',
|
||||
} as const
|
||||
@@ -1,5 +1,6 @@
|
||||
// 메일 화면·검증 상수 — 한도 값은 docs/mail/api-contract.md 와 같다 (서버가 최종 검증한다)
|
||||
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import type { IconName } from '@/shared/constants/icons'
|
||||
import type { MailboxRole } from '@/shared/types/mail'
|
||||
|
||||
@@ -132,17 +133,63 @@ export type ComposeMode = keyof typeof COMPOSE_TITLES
|
||||
export const MAIL_SENT_MESSAGE = '메일을 보냈습니다.'
|
||||
export const MAIL_SENT_ACTION_LABEL = '보낸편지함 보기'
|
||||
|
||||
/** 임시 저장본을 불러오는 동안의 쓰기 화면 제목 — 불러오면 답장 · 전달 · 새 메일로 바뀐다 */
|
||||
export const COMPOSE_LOADING_TITLE = '메일 쓰기'
|
||||
|
||||
/**
|
||||
* 쓰기 화면 나가기 전 확인 (디자인 README "나가기 전 확인") — 받는 사람 · 제목 · 본문 · 첨부를 바꾼 채 화면을 떠날 때.
|
||||
* [계속 쓰기]가 처음 초점이고 Esc 와 같다.
|
||||
* 쓰기 화면 나가기 전 확인 (디자인 README "2단계 › 임시 저장 › 나가기 확인") — 떠나기 전에 남은 변경을 먼저 저장하고,
|
||||
* 저장하지 못한 내용이 있을 때만 묻는다. [계속 쓰기]가 처음 초점이고 Esc 와 같다.
|
||||
*/
|
||||
export const COMPOSE_LEAVE_DIALOG = {
|
||||
title: '보내지 않은 메일이 있습니다',
|
||||
message: '이 화면을 나가면 쓰던 내용이 사라집니다.',
|
||||
title: '저장하지 못한 내용이 있습니다',
|
||||
message: '이 화면을 나가면 마지막으로 바꾼 내용이 사라집니다.',
|
||||
stay: '계속 쓰기',
|
||||
leave: '나가기',
|
||||
} as const
|
||||
|
||||
// ── 임시 저장 (계약 2절 "임시 저장" · 디자인 README "2단계 › 임시 저장") ─────────────
|
||||
/** 입력이 이만큼 멈추면 자동 저장한다 */
|
||||
export const DRAFT_IDLE_SAVE_MS = 3_000
|
||||
/** 자동 저장 사이의 최소 간격 — 10초에 한 번까지 */
|
||||
export const DRAFT_MIN_SAVE_INTERVAL_MS = 10_000
|
||||
/**
|
||||
* 쉬지 않고 쓰는 동안에도 저장한다 — 바꾼 내용이 처음 생긴 뒤(마지막 저장을 시작한 뒤 처음 바꾼 때부터) 이만큼 지나면
|
||||
* 입력이 멈추기를 기다리지 않는다 (몇 분을 쉬지 않고 쓰다 브라우저가 죽어도 잃는 것이 이만큼이다). 10초 최소 간격은 그대로다
|
||||
*/
|
||||
export const DRAFT_MAX_WAIT_MS = 30_000
|
||||
/**
|
||||
* 떠나기 전 저장(진행 중인 저장 기다리기 + 남은 변경 저장)을 기다리는 시간의 상한 — 넘으면 "저장하지 못한 내용이 있습니다"를 묻는다.
|
||||
* 큰 첨부가 든 저장은 몇십 초씩 걸릴 수 있어 그동안 이동이 멈춘 것처럼 보이지 않게 한다. 저장은 뒤에서 이어진다
|
||||
*/
|
||||
export const DRAFT_LEAVE_SAVE_WAIT_MS = 5_000
|
||||
/**
|
||||
* 자동 저장이 실패했을 때 다시 해 보기까지 — 실패할수록 길게, 마지막 값에서 멈춘다.
|
||||
* 다시 해 보면 나아질 수 있는 실패(DRAFT_RETRY_CODES)에만 쓴다 — 그 밖은 내용을 바꾸면 다시 저장한다
|
||||
*/
|
||||
export const DRAFT_RETRY_DELAYS_MS: readonly number[] = [15_000, 30_000, 60_000]
|
||||
/** 저절로 다시 해 볼 실패 — 메일 서버 장애 · 메일함 용량 · 요청 한도 · 네트워크 */
|
||||
export const DRAFT_RETRY_CODES: readonly ErrorCode[] = ['MAIL_001', 'MAIL_003', 'REQ_001', 'SYS_001']
|
||||
|
||||
/** 아래 단추 줄의 상태 글 (디자인 원본 ComposeAssist) — 시각은 formatDraftSavedTime */
|
||||
export const DRAFT_STATUS_TEXT = {
|
||||
saving: '저장하는 중…',
|
||||
saved: (time: string) => `${time}에 임시 저장됨`,
|
||||
failed: '임시 저장하지 못했습니다',
|
||||
retry: '다시 시도',
|
||||
replaced: (time: string) => `다른 곳에서 바뀐 저장본이 있어 새로 저장했습니다 · ${time}`,
|
||||
} as const
|
||||
|
||||
/** 쓰기 화면 머리의 휴지통 단추 · 버린 뒤 알림 (디자인 원본 ComposeAssist "임시 저장본 삭제") */
|
||||
export const DRAFT_DISCARD_LABEL = '이 메일 삭제'
|
||||
export const DRAFT_DISCARDED_MESSAGE = '임시 저장한 메일을 휴지통으로 옮겼습니다.'
|
||||
export const DRAFT_UNDO_LABEL = '되돌리기'
|
||||
/** 되돌리기가 끝났을 때 — 캔버스에는 없다 (2026-10-27 구현하며 정함 — 되돌린 것을 알 수 있게) */
|
||||
export const DRAFT_RESTORED_MESSAGE = '임시보관함으로 되돌렸습니다.'
|
||||
|
||||
/** 임시보관함 목록 — 받는 사람 · 제목이 없을 때 (디자인 원본 ComposeAssist "임시보관함 목록") */
|
||||
export const DRAFT_NO_RECIPIENTS = '(받는 사람 없음)'
|
||||
export const MAIL_NO_SUBJECT = '(제목 없음)'
|
||||
|
||||
// ── 읽기 화면의 본문 안내 (디자인 README "읽기 화면의 본문 안내" · 계약 2절 "읽기 한도") ─────────
|
||||
/**
|
||||
* 서버가 본문을 다 주지 못했을 때 본문 위에 두는 안내 — "외부 이미지를 차단했습니다"와 같은 모양.
|
||||
|
||||
@@ -45,6 +45,15 @@ export interface Attachment {
|
||||
size: number
|
||||
}
|
||||
|
||||
/** 메일 상세의 첨부 — 본문 안에 그림으로 들어간 것인지 함께 온다 (계약 2절 "MessageAttachment") */
|
||||
export interface MessageAttachment extends Attachment {
|
||||
/**
|
||||
* 돌려준 htmlBody 안에 그림으로 들어갔다(cid → data:) — 읽기 화면의 첨부 목록에서 뺀다.
|
||||
* 전달할 때는 원문 그림을 잃지 않게 함께 붙인다 (인용 본문에서는 그림이 빠진다). 필드가 없으면(이전 서버) false 로 본다
|
||||
*/
|
||||
isInline?: boolean
|
||||
}
|
||||
|
||||
export interface MessageDetail extends MessageSummary {
|
||||
cc: Address[]
|
||||
bcc: Address[]
|
||||
@@ -68,7 +77,8 @@ export interface MessageDetail extends MessageSummary {
|
||||
* → "앞부분만 표시" 안내 · 답장 · 전달 인용 끝에 생략 표시. 필드가 없으면 false 로 본다
|
||||
*/
|
||||
isBodyTruncated?: boolean
|
||||
attachments: Attachment[]
|
||||
/** 본문 그림(isInline)도 함께 온다 — 읽기 화면은 listedAttachments 로 거른다 */
|
||||
attachments: MessageAttachment[]
|
||||
}
|
||||
|
||||
/** GET /mail-quota — 메일함 패널 하단 사용량 표시와 첨부 사전 검사에 쓴다 */
|
||||
@@ -117,9 +127,100 @@ export interface SendMessagePayload {
|
||||
attachments: AttachmentRef[]
|
||||
/** 답장할 메일의 id — 스레드 헤더는 서버가 채운다 */
|
||||
inReplyTo?: string
|
||||
/** 이어 쓴 임시 저장본 id — 보낸 뒤 서버가 그 초안을 지운다 (계약 2절 "임시 저장") */
|
||||
draftId?: string
|
||||
}
|
||||
|
||||
/** POST /messages 응답 — 보낸편지함에 저장된 메일 id */
|
||||
export interface SendMessageResult {
|
||||
id: string
|
||||
}
|
||||
|
||||
// ── 임시 저장 (계약 2절 "임시 저장") ───────────────────────
|
||||
|
||||
/** GET /drafts/{id} — 이어 쓸 임시 저장본 */
|
||||
export interface Draft {
|
||||
/** 저장할 때마다 바뀐다 — 응답의 id 로 바꿔 쓴다 */
|
||||
id: string
|
||||
to: Address[]
|
||||
cc: Address[]
|
||||
bcc: Address[]
|
||||
subject: string
|
||||
/** 텍스트 대체 본문 */
|
||||
body: string
|
||||
/** 서식 본문 — 보내는 정화를 거친 값 (없거나 비면 null) */
|
||||
bodyHtml: string | null
|
||||
/** 이 판의 첨부 — id 가 저장할 때마다 바뀐다 */
|
||||
attachments: Attachment[]
|
||||
/** 답장할 메일 id (없거나 원 메일이 사라졌으면 null) */
|
||||
inReplyTo: string | null
|
||||
/** 마지막으로 저장한 시각 */
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
/** POST /drafts · PUT /drafts/{id} 본문 — 발송 본문과 같은 필드 · 한도 (받는 사람 0명 가능, draftId 없음) */
|
||||
export type DraftPayload = Omit<SendMessagePayload, 'draftId'>
|
||||
|
||||
/** POST /drafts · PUT /drafts/{id} 응답 — 새 id 와 그 판의 첨부 */
|
||||
export interface DraftSaved {
|
||||
id: string
|
||||
attachments: Attachment[]
|
||||
updatedAt: string
|
||||
}
|
||||
|
||||
// ── 서명 (계약 2절 "서명") ─────────────────────────────
|
||||
|
||||
export type SignatureImageType = 'image/png' | 'image/jpeg' | 'image/gif'
|
||||
|
||||
export interface SignatureImage {
|
||||
id: string
|
||||
type: SignatureImageType
|
||||
/** 바이트 */
|
||||
size: number
|
||||
/** 원본 픽셀 */
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
|
||||
/** GET · PUT /mail-signature — 사용자마다 하나 */
|
||||
export interface MailSignature {
|
||||
/** 서명 서식 — 서식 본문 규칙 + 서명 그림 */
|
||||
bodyHtml: string | null
|
||||
/** 텍스트 서명 (텍스트 대체 본문에 넣는다) */
|
||||
body: string | null
|
||||
/** 새 메일에 넣는다 */
|
||||
isForNew: boolean
|
||||
/** 답장 · 전달에 넣는다 — 인용 위 */
|
||||
isForReply: boolean
|
||||
/** 서명이 쓰는 그림 */
|
||||
images: SignatureImage[]
|
||||
/** 저장한 적 없으면 null */
|
||||
updatedAt: string | null
|
||||
}
|
||||
|
||||
/** PUT /mail-signature 본문 */
|
||||
export interface SaveSignaturePayload {
|
||||
bodyHtml: string | null
|
||||
body: string | null
|
||||
isForNew: boolean
|
||||
isForReply: boolean
|
||||
}
|
||||
|
||||
// ── 받는 사람 추천 (계약 2절 "받는 사람 추천") ────────────────
|
||||
|
||||
/** directory = 사내(웹메일에 로그인한 적 있는 직원), recent = 최근 주고받은 주소 */
|
||||
export type RecipientSuggestionSource = 'directory' | 'recent'
|
||||
|
||||
export interface RecipientSuggestion {
|
||||
name: string | null
|
||||
email: string
|
||||
source: RecipientSuggestionSource
|
||||
}
|
||||
|
||||
/** GET /recipient-suggestions 쿼리 */
|
||||
export interface RecipientSuggestionQuery {
|
||||
/** 1~100자 — 이름 · 주소에 이 글자가 든 것만 */
|
||||
q?: string
|
||||
limit?: number
|
||||
cursor?: string
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
// 쓰기 화면의 "바꿈" 판정 — 순수 함수 (디자인 README "나가기 전 확인")
|
||||
// 쓰기 화면을 다 연 때(답장 · 전달 초안을 채운 뒤)의 양식을 찍어 두고, 지금 양식과 다르면 떠나기 전에 묻는다.
|
||||
// 쓰기 화면의 "바꿈" 판정 — 순수 함수 (디자인 README "2단계 › 임시 저장")
|
||||
// 마지막으로 임시 저장한 양식(저장한 적 없으면 쓰기 화면을 다 연 때 — 답장 · 전달 초안을 채운 뒤)을 찍어 두고,
|
||||
// 지금 양식과 다르면 자동 저장하고 떠나기 전에 저장한다 (저장하지 못하면 묻는다).
|
||||
// 본문은 편집기가 따로 잰다 (MailEditor 의 isChanged) — 여기서는 받는 사람 · 제목 · 첨부만 본다.
|
||||
|
||||
import type { RecipientToken } from '@/shared/utils/mail-address'
|
||||
@@ -10,7 +11,7 @@ export interface ComposeFormSnapshot {
|
||||
cc: readonly string[]
|
||||
bcc: readonly string[]
|
||||
subject: string
|
||||
/** 첨부 목록 항목의 키 — 붙이거나(올리는 중 · 실패 포함) 빼면 달라진다 */
|
||||
/** 첨부 목록 항목의 키 — 쓰기 화면은 저장본에 싣는 첨부(올리기를 마친 것 · 원본 첨부)만 넘긴다 */
|
||||
attachments: readonly number[]
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
formatDraftSavedTime,
|
||||
formatFileSize,
|
||||
formatMailDateTime,
|
||||
formatMailListTime,
|
||||
@@ -19,6 +20,19 @@ const SEOUL = 'Asia/Seoul'
|
||||
/** 2026-10-26(월) 오후 3시 (서울) */
|
||||
const NOW = new Date('2026-10-26T06:00:00Z')
|
||||
|
||||
describe('formatDraftSavedTime (임시 저장 상태 글)', () => {
|
||||
it('오늘은 시각만 — "오후 3:12"', () => {
|
||||
expect(formatDraftSavedTime('2026-10-26T06:12:00Z', NOW, SEOUL)).toBe('오후 3:12')
|
||||
})
|
||||
|
||||
it('어제 · 올해 · 그 이전은 날짜를 붙인다 (이어 쓰는 저장본은 다른 날 저장했을 수 있다)', () => {
|
||||
expect(formatDraftSavedTime('2026-10-25T01:00:00Z', NOW, SEOUL)).toBe('어제 오전 10:00')
|
||||
expect(formatDraftSavedTime('2026-10-24T05:30:00Z', NOW, SEOUL)).toBe('10월 24일 오후 2:30')
|
||||
expect(formatDraftSavedTime('2025-10-24T05:30:00Z', NOW, SEOUL)).toBe('2025. 10. 24. 오후 2:30')
|
||||
expect(formatDraftSavedTime('not-a-date', NOW, SEOUL)).toBe('')
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatMailListTime', () => {
|
||||
it('오늘 받은 메일은 시각만 보인다', () => {
|
||||
expect(formatMailListTime('2026-10-26T01:42:00Z', NOW, SEOUL)).toBe('오전 10:42')
|
||||
|
||||
@@ -186,6 +186,30 @@ export function formatMailTableDate(value: string, now: Date = new Date(), timeZ
|
||||
return target.year === today.year ? monthDay : `${target.year}.${monthDay}`
|
||||
}
|
||||
|
||||
/**
|
||||
* 임시 저장 시각 — 쓰기 화면 아래 단추 줄의 상태 글 (디자인 원본 ComposeAssist: "오후 3:12에 임시 저장됨")
|
||||
* - 오늘: "오후 3:12"
|
||||
* - 어제: "어제 오후 3:12"
|
||||
* - 올해: "10월 24일 오후 3:12"
|
||||
* - 그 이전: "2025. 10. 24. 오후 3:12"
|
||||
* 이어 쓰는 저장본은 다른 날 저장했을 수 있어 날짜를 붙인다.
|
||||
*/
|
||||
export function formatDraftSavedTime(value: string, now: Date = new Date(), timeZone?: string): string {
|
||||
const date = parseDate(value)
|
||||
if (!date) return ''
|
||||
|
||||
const time = new Intl.DateTimeFormat('ko-KR', { timeZone, hour: 'numeric', minute: '2-digit' }).format(date)
|
||||
const target = calendarDayOf(date, timeZone)
|
||||
const today = calendarDayOf(now, timeZone)
|
||||
if (isSameDay(target, today)) return time
|
||||
if (isSameDay(target, previousDay(today))) return `어제 ${time}`
|
||||
const dayOptions: Intl.DateTimeFormatOptions =
|
||||
target.year === today.year
|
||||
? { timeZone, month: 'long', day: 'numeric' }
|
||||
: { timeZone, year: 'numeric', month: 'numeric', day: 'numeric' }
|
||||
return `${new Intl.DateTimeFormat('ko-KR', dayOptions).format(date)} ${time}`
|
||||
}
|
||||
|
||||
/** 메일 본문 머리의 날짜 (디자인 원본: "2026. 10. 26. (월) 오전 10:42") */
|
||||
export function formatMailDateTime(value: string, timeZone?: string): string {
|
||||
const date = parseDate(value)
|
||||
|
||||
@@ -45,3 +45,14 @@ export function formatShortcut(keys: ShortcutKeys, apple: boolean): ShortcutText
|
||||
const rest = keys.filter((key) => !APPLE_MODIFIERS.some(([modifier]) => modifier === key))
|
||||
return { label: [...modifiers, ...rest].join(''), aria }
|
||||
}
|
||||
|
||||
/** 입력기가 처리 중인 키의 keyCode — Safari · macOS 한글 입력은 조합을 끝내는 Enter 를 isComposing 없이 이 값으로 보낸다 */
|
||||
const IME_PROCESS_KEY_CODE = 229
|
||||
|
||||
/**
|
||||
* 한글 등 입력기가 글자를 조합하는 중의 키인가 — 이때의 Enter · Tab 은 글자 확정이다 (토큰 · 추천 · 제출로 쓰지 않는다).
|
||||
* isComposing 만 보면 Safari · macOS 에서 조합을 끝내는 Enter 가 새어 든다 (keyCode 229 로 온다)
|
||||
*/
|
||||
export function isImeComposing(event: KeyboardEvent): boolean {
|
||||
return event.isComposing || event.keyCode === IME_PROCESS_KEY_CODE
|
||||
}
|
||||
|
||||
@@ -58,6 +58,11 @@ export function summarizeRecipients(list: readonly Address[]): string {
|
||||
return rest.length > 0 ? `${displayName(first)} 외 ${rest.length}명` : displayName(first)
|
||||
}
|
||||
|
||||
/** 임시보관함 목록의 받는 사람 — 이름(없으면 주소)을 쉼표로 잇는다 ("박준호, 인프라팀"). 없으면 빈 문자열 */
|
||||
export function draftRecipientNames(list: readonly Address[]): string {
|
||||
return list.map(displayName).filter((name) => name !== '').join(', ')
|
||||
}
|
||||
|
||||
/** 받는 사람 칸의 토큰 하나 */
|
||||
export interface RecipientToken {
|
||||
/** 화면에 보이는 글자 — 입력한 그대로 */
|
||||
|
||||
@@ -1,10 +1,12 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { BYTES_PER_MB, MAIL_MAX_ATTACHMENTS } from '@/shared/constants/mail'
|
||||
import type { MessageAttachment } from '@/shared/types/mail'
|
||||
import {
|
||||
attachmentHint,
|
||||
extensionOf,
|
||||
isAllowedAttachmentName,
|
||||
listedAttachments,
|
||||
precheckAttachment,
|
||||
type AttachedSummary,
|
||||
type AttachmentLimits,
|
||||
@@ -18,6 +20,18 @@ const FORWARDED: AttachmentLimits = { maxBytes: null, totalMaxBytes: TOTAL, maxC
|
||||
const NONE: AttachedSummary = { count: 0, bytes: 0 }
|
||||
const bytes = (value: number): AttachedSummary => ({ count: 1, bytes: value })
|
||||
|
||||
describe('listedAttachments — 읽기 화면의 첨부 목록', () => {
|
||||
it('본문 안에 그림으로 보인 첨부(isInline)만 뺀다 — 표시 값이 없으면 첨부로 본다', () => {
|
||||
const attachments: MessageAttachment[] = [
|
||||
{ id: 'a1', name: '보고서.pdf', type: 'application/pdf', size: 2048, isInline: false },
|
||||
{ id: 'a2', name: 'signature-1.png', type: 'image/png', size: 1024, isInline: true },
|
||||
// 표시 값을 주지 않는(이전) 서버
|
||||
{ id: 'a3', name: '자료.zip', type: 'application/zip', size: 4096 },
|
||||
]
|
||||
expect(listedAttachments(attachments).map((attachment) => attachment.id)).toEqual(['a1', 'a3'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('precheckAttachment', () => {
|
||||
it('허용하지 않는 확장자는 FILE_001', () => {
|
||||
expect(precheckAttachment({ name: '설치.exe', size: 10 }, UPLOAD, NONE)).toBe('FILE_001')
|
||||
|
||||
@@ -6,6 +6,15 @@ import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import { MAIL_ATTACHMENT_EXTENSIONS, MAIL_ATTACHMENT_KINDS_LABEL } from '@/shared/constants/mail'
|
||||
import { formatMegabytes } from '@/shared/utils/format'
|
||||
|
||||
/**
|
||||
* 읽기 화면의 첨부 목록에 보일 첨부 — 본문 안에 그림으로 들어간 것(isInline)은 뺀다 (계약 2절 "MessageAttachment").
|
||||
* 서명 그림 · 받은 메일의 본문 그림은 본문에 보이므로 첨부가 아니다. 필드가 없으면(이전 서버) 첨부로 본다.
|
||||
* 전달은 이것을 쓰지 않는다 — 인용에서 그림이 빠지므로 원문 그림까지 모두 붙인다 (mail-forward.ts).
|
||||
*/
|
||||
export function listedAttachments<T extends { isInline?: boolean }>(attachments: readonly T[]): T[] {
|
||||
return attachments.filter((attachment) => attachment.isInline !== true)
|
||||
}
|
||||
|
||||
export function extensionOf(name: string): string {
|
||||
const dot = name.lastIndexOf('.')
|
||||
return dot === -1 ? '' : name.slice(dot + 1).toLowerCase()
|
||||
|
||||
@@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_QUOTE_OMITTED_MARK, MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { buildForwardDraft, buildForwardSubject } from './mail-forward'
|
||||
import { buildForwardDraft, buildForwardSubject, isForwardSubject } from './mail-forward'
|
||||
|
||||
const ATTACHMENT = { id: 'att1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
|
||||
@@ -46,6 +46,23 @@ describe('buildForwardSubject', () => {
|
||||
it('제목이 없어도 접두어만 남는다', () => {
|
||||
expect(buildForwardSubject('')).toBe('Fwd:')
|
||||
})
|
||||
|
||||
it('isForwardSubject — 이어 쓰는 저장본의 쓰기 화면 제목(전달)을 정한다', () => {
|
||||
expect(isForwardSubject('Fwd: 일정')).toBe(true)
|
||||
expect(isForwardSubject('전달: 일정')).toBe(true)
|
||||
expect(isForwardSubject('Re: 일정')).toBe(false)
|
||||
expect(isForwardSubject('일정 Fwd:')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForwardDraft — 본문 그림(isInline)', () => {
|
||||
it('본문에 보인 그림도 원본 첨부로 함께 붙인다 — 인용 본문에서는 그림이 빠지므로 원문 그림을 잃지 않게', () => {
|
||||
const report = { id: 'a1', name: '보고서.pdf', type: 'application/pdf', size: 2048, isInline: false }
|
||||
const logo = { id: 'a2', name: 'signature-1.png', type: 'image/png', size: 1024, isInline: true }
|
||||
const original = message({ attachments: [report, logo] })
|
||||
|
||||
expect(buildForwardDraft(original).attachments.map((attachment) => attachment.id)).toEqual(['a1', 'a2'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForwardDraft', () => {
|
||||
|
||||
@@ -9,26 +9,38 @@ import { buildQuoteHtml, forwardQuoteRows, originalBodyHtml } from '@/shared/uti
|
||||
|
||||
export interface ForwardDraft {
|
||||
subject: string
|
||||
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 전달하는 말을 쓴다) + 원문 인용 */
|
||||
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 전달하는 말을 쓴다) + (서명) + 원문 인용 */
|
||||
html: string
|
||||
/** 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다 */
|
||||
/**
|
||||
* 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다.
|
||||
* 본문 그림(isInline)도 함께 붙인다 — 인용 본문에서는 그림이 빠지므로 원문 그림을 잃지 않게 (계약 2절 "MessageAttachment")
|
||||
*/
|
||||
attachments: Attachment[]
|
||||
}
|
||||
|
||||
/** 이미 전달 접두어가 있으면 그대로 둔다 ("Fwd:" · "FW:" · "전달:") */
|
||||
const FORWARD_PREFIX_PATTERN = /^\s*(?:fwd?|전달)\s*:/i
|
||||
|
||||
/** 전달 제목인가 — "Fwd:" · "FW:" · "전달:" 로 시작한다 (이어 쓰는 임시 저장본의 쓰기 화면 제목을 정할 때) */
|
||||
export function isForwardSubject(subject: string): boolean {
|
||||
return FORWARD_PREFIX_PATTERN.test(subject)
|
||||
}
|
||||
|
||||
export function buildForwardSubject(subject: string): string {
|
||||
const trimmed = subject.trim()
|
||||
if (FORWARD_PREFIX_PATTERN.test(trimmed)) return trimmed
|
||||
return `${MAIL_FORWARD_PREFIX}${trimmed}`.trimEnd()
|
||||
}
|
||||
|
||||
/** 전달 초안 — 제목은 255자에 맞춘다 */
|
||||
export function buildForwardDraft(message: MessageDetail, timeZone?: string): ForwardDraft {
|
||||
/** 전달 초안 — 제목은 255자에 맞춘다. 서명(signatureHtml)이 있으면 인용 위에 넣는다 */
|
||||
export function buildForwardDraft(
|
||||
message: MessageDetail,
|
||||
timeZone?: string,
|
||||
signatureHtml: string | null = null,
|
||||
): ForwardDraft {
|
||||
return {
|
||||
subject: fitDraftSubject(buildForwardSubject(message.subject)),
|
||||
html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message)),
|
||||
html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message), signatureHtml),
|
||||
attachments: [...message.attachments],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -190,4 +190,10 @@ describe('buildQuoteHtml', () => {
|
||||
'<p>원문</p></blockquote>',
|
||||
)
|
||||
})
|
||||
|
||||
it('서명은 인용 위 — 쓸 줄 · 빈 줄 하나 · 서명 · 인용 (디자인 README "2단계 › 서명")', () => {
|
||||
expect(buildQuoteHtml([{ label: '제목', value: '일정' }], '<p>원문</p>', '<p>김아라</p>')).toMatch(
|
||||
/^<p><\/p><p><\/p><p>김아라<\/p><blockquote data-mail-quote="">/,
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -135,13 +135,18 @@ export function forwardQuoteRows(message: HeaderSource, timeZone?: string): Quot
|
||||
return rows
|
||||
}
|
||||
|
||||
/** 내가 쓸 빈 문단 + "원본 메일" 머리 + 원문을 담은 인용 */
|
||||
export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string): string {
|
||||
/**
|
||||
* 내가 쓸 빈 문단 + (서명) + "원본 메일" 머리 + 원문을 담은 인용.
|
||||
* 서명이 있으면 인용 위에 둔다 — 쓸 줄 · 빈 줄 하나 · 서명 · 인용 (디자인 README "2단계 › 서명").
|
||||
* signatureHtml 은 서버가 정화해 준 서명 HTML 이다 (편집기가 자기 스키마로 다시 읽는다).
|
||||
*/
|
||||
export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string, signatureHtml: string | null = null): string {
|
||||
const header = rows
|
||||
.map((row) => `<tr><td>${escapeHtml(row.label)}</td><td>${escapeHtml(row.value)}</td></tr>`)
|
||||
.join('')
|
||||
return (
|
||||
'<p></p>' +
|
||||
(signatureHtml ? `<p></p>${signatureHtml}` : '') +
|
||||
'<blockquote data-mail-quote="">' +
|
||||
`<p data-quote-header=""><strong>${escapeHtml(MAIL_QUOTE_HEADING)}</strong></p>` +
|
||||
`<table data-quote-header=""><tbody>${header}</tbody></table>` +
|
||||
|
||||
@@ -151,6 +151,12 @@ describe('buildReplyDraft', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('서명을 주면 인용 위에 넣는다', () => {
|
||||
const { html } = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul', '<p>김아라 · 인프라팀</p>')
|
||||
|
||||
expect(html.startsWith('<p></p><p></p><p>김아라 · 인프라팀</p><blockquote data-mail-quote="">')).toBe(true)
|
||||
})
|
||||
|
||||
it('답장 머리에는 받는 사람 · 참조가 없다 (전달에만)', () => {
|
||||
const { html } = buildReplyDraft(message({ cc: [INFRA] }), ME, 'reply', 'Asia/Seoul')
|
||||
|
||||
|
||||
@@ -14,7 +14,7 @@ export interface ReplyDraft {
|
||||
to: Address[]
|
||||
cc: Address[]
|
||||
subject: string
|
||||
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 쓴다) + 원문 인용 */
|
||||
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 쓴다) + (서명) + 원문 인용 */
|
||||
html: string
|
||||
/** 발송 본문의 inReplyTo — 답장할 메일의 id */
|
||||
inReplyTo: string
|
||||
@@ -83,19 +83,20 @@ export function buildReplyRecipients(
|
||||
return { to, cc }
|
||||
}
|
||||
|
||||
/** 답장 초안 — 제목은 255자에 맞춘다 */
|
||||
/** 답장 초안 — 제목은 255자에 맞춘다. 서명(signatureHtml)이 있으면 인용 위에 넣는다 */
|
||||
export function buildReplyDraft(
|
||||
message: MessageDetail,
|
||||
myEmail: string,
|
||||
mode: ReplyMode,
|
||||
timeZone?: string,
|
||||
signatureHtml: string | null = null,
|
||||
): ReplyDraft {
|
||||
const { to, cc } = buildReplyRecipients(message, myEmail, mode)
|
||||
return {
|
||||
to,
|
||||
cc,
|
||||
subject: fitDraftSubject(buildReplySubject(message.subject)),
|
||||
html: buildQuoteHtml(replyQuoteRows(message, timeZone), originalBodyHtml(message)),
|
||||
html: buildQuoteHtml(replyQuoteRows(message, timeZone), originalBodyHtml(message), signatureHtml),
|
||||
inReplyTo: message.id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { MailSignature } from '@/shared/types/mail'
|
||||
import {
|
||||
imageSizeOf,
|
||||
imageWidthFor,
|
||||
insertedImageWidth,
|
||||
newMailHtmlWithSignature,
|
||||
originalImageWidth,
|
||||
precheckSignatureImage,
|
||||
signatureHtmlFor,
|
||||
signatureImageUploadName,
|
||||
} from './mail-signature'
|
||||
|
||||
const BASE: MailSignature = {
|
||||
bodyHtml: '<p>김아라</p>',
|
||||
body: '김아라',
|
||||
isForNew: true,
|
||||
isForReply: true,
|
||||
images: [],
|
||||
updatedAt: null,
|
||||
}
|
||||
|
||||
describe('signatureHtmlFor — 쓰기 화면에 넣을 서명', () => {
|
||||
it('넣기로 한 쓰기 종류에만, 서식 서명을 먼저', () => {
|
||||
expect(signatureHtmlFor(BASE, 'new')).toBe('<p>김아라</p>')
|
||||
expect(signatureHtmlFor({ ...BASE, isForNew: false }, 'new')).toBeNull()
|
||||
expect(signatureHtmlFor({ ...BASE, isForReply: false }, 'reply')).toBeNull()
|
||||
expect(signatureHtmlFor({ ...BASE, isForReply: false }, 'new')).toBe('<p>김아라</p>')
|
||||
})
|
||||
|
||||
it('서식 서명이 없으면 텍스트 서명을 문단으로, 둘 다 비었으면 넣지 않는다', () => {
|
||||
expect(signatureHtmlFor({ ...BASE, bodyHtml: null, body: '김아라\n인프라팀\n' }, 'new')).toBe('<p>김아라</p><p>인프라팀</p>')
|
||||
expect(signatureHtmlFor({ ...BASE, bodyHtml: ' ', body: ' ' }, 'new')).toBeNull()
|
||||
expect(signatureHtmlFor(null, 'new')).toBeNull()
|
||||
})
|
||||
|
||||
it('새 메일은 쓸 줄 · 빈 줄 하나 뒤에', () => {
|
||||
expect(newMailHtmlWithSignature('<p>김아라</p>')).toBe('<p></p><p></p><p>김아라</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('precheckSignatureImage — PNG · JPG · GIF, 300KB, 3장', () => {
|
||||
it.each([
|
||||
[{ name: '로고.png', type: 'image/png', size: 1000 }, 0, null],
|
||||
[{ name: '로고.JPG', type: 'image/jpeg', size: 1000 }, 2, null],
|
||||
[{ name: '로고.jpeg', type: 'image/jpeg', size: 300 * 1024 }, 0, null],
|
||||
[{ name: '로고.gif', type: 'image/gif', size: 1 }, 0, null],
|
||||
// 붙여 넣은 그림은 이름이 없을 수 있다 — 형식만 본다
|
||||
[{ name: '', type: 'image/png', size: 1 }, 0, null],
|
||||
[{ name: '로고.webp', type: 'image/webp', size: 1 }, 0, 'FILE_001'],
|
||||
[{ name: '로고.svg', type: 'image/svg+xml', size: 1 }, 0, 'FILE_001'],
|
||||
[{ name: '로고.png', type: 'image/gif', size: 1 }, 0, 'FILE_001'],
|
||||
[{ name: '로고.png', type: '', size: 1 }, 0, 'FILE_001'],
|
||||
[{ name: '로고.png', type: 'image/png', size: 300 * 1024 + 1 }, 0, 'FILE_002'],
|
||||
[{ name: '로고.png', type: 'image/png', size: 1 }, 3, 'VAL_001'],
|
||||
] as const)('%o · 이미 %i장 → %s', (file, occupied, expected) => {
|
||||
expect(precheckSignatureImage(file, occupied)).toBe(expected)
|
||||
})
|
||||
|
||||
it('올릴 이름 — 확장자가 없으면 형식에 맞는 확장자를 붙인다', () => {
|
||||
expect(signatureImageUploadName({ name: '로고.png', type: 'image/png', size: 1 })).toBe('로고.png')
|
||||
expect(signatureImageUploadName({ name: '', type: 'image/jpeg', size: 1 })).toBe('서명 그림.jpg')
|
||||
expect(signatureImageUploadName({ name: 'logo', type: 'image/gif', size: 1 })).toBe('logo.gif')
|
||||
})
|
||||
})
|
||||
|
||||
describe('그림 크기 — 작게 96 · 보통 144 · 원본(600 이하)', () => {
|
||||
it('넣을 때는 보통, 원래 폭이 더 작으면 원래 폭', () => {
|
||||
expect(insertedImageWidth(480)).toBe(144)
|
||||
expect(insertedImageWidth(80)).toBe(80)
|
||||
})
|
||||
|
||||
it('원본은 원래 폭, 600 을 넘으면 600', () => {
|
||||
expect(originalImageWidth(1200)).toBe(600)
|
||||
expect(originalImageWidth(320)).toBe(320)
|
||||
expect(imageWidthFor('original', 800)).toBe(600)
|
||||
expect(imageWidthFor('small', 800)).toBe(96)
|
||||
})
|
||||
|
||||
it('지금 폭이 어느 칸인가 — 작게 · 보통 · 원본 순, 맞는 칸이 없으면 null', () => {
|
||||
expect(imageSizeOf(96, 480)).toBe('small')
|
||||
expect(imageSizeOf(144, 144)).toBe('medium')
|
||||
expect(imageSizeOf(480, 480)).toBe('original')
|
||||
expect(imageSizeOf(600, 1200)).toBe('original')
|
||||
expect(imageSizeOf(200, 480)).toBeNull()
|
||||
expect(imageSizeOf(null, 480)).toBe('original')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,102 @@
|
||||
// 메일 서명 — 순수 함수 (계약 2절 "서명" · 디자인 README "2단계 › 서명")
|
||||
|
||||
import type { ErrorCode } from '@/shared/constants/error-codes.generated'
|
||||
import {
|
||||
SIGNATURE_IMAGE_MAX_BYTES,
|
||||
SIGNATURE_IMAGE_MAX_COUNT,
|
||||
SIGNATURE_IMAGE_SIZES,
|
||||
SIGNATURE_IMAGE_TYPES_BY_EXTENSION,
|
||||
type SignatureImageSize,
|
||||
} from '@/shared/constants/mail-signature'
|
||||
import type { MailSignature } from '@/shared/types/mail'
|
||||
import { extensionOf } from '@/shared/utils/mail-attachment'
|
||||
import { textToParagraphHtml } from '@/shared/utils/mail-quote'
|
||||
import { IMAGE_MAX_WIDTH } from '@/shared/utils/outgoing-html-rules'
|
||||
|
||||
export type SignatureUse = 'new' | 'reply'
|
||||
|
||||
/** 받는 그림 MIME 형식 */
|
||||
const SIGNATURE_IMAGE_TYPES: readonly string[] = Object.values(SIGNATURE_IMAGE_TYPES_BY_EXTENSION)
|
||||
|
||||
/**
|
||||
* 쓰기 화면에 넣을 서명 HTML — 그 쓰기 종류에 넣기로 했고(isForNew · isForReply) 내용이 있을 때만.
|
||||
* 서식 서명(bodyHtml)이 있으면 그것, 없으면 텍스트 서명(body)을 문단으로. 둘 다 비었으면 null.
|
||||
*/
|
||||
export function signatureHtmlFor(signature: MailSignature | null, use: SignatureUse): string | null {
|
||||
if (!signature) return null
|
||||
const enabled = use === 'new' ? signature.isForNew : signature.isForReply
|
||||
if (!enabled) return null
|
||||
const html = signature.bodyHtml?.trim() ?? ''
|
||||
if (html !== '') return html
|
||||
const text = signature.body?.replace(/\s+$/, '') ?? ''
|
||||
return text.trim() === '' ? null : textToParagraphHtml(text)
|
||||
}
|
||||
|
||||
/** 새 메일 본문 — 쓸 줄 · 빈 줄 하나 · 서명 (서명은 본문 끝, 빈 줄 하나 뒤) */
|
||||
export function newMailHtmlWithSignature(signatureHtml: string): string {
|
||||
return `<p></p><p></p>${signatureHtml}`
|
||||
}
|
||||
|
||||
/** 그림 파일 사전 검사에 쓰는 모양 — File 과 같은 이름 · 형식 · 크기 */
|
||||
export interface SignatureImageCandidate {
|
||||
name: string
|
||||
type: string
|
||||
size: number
|
||||
}
|
||||
|
||||
/**
|
||||
* 서명 그림 사전 검사 — 걸리면 그 에러 코드 (문구는 사전에서), 통과하면 null. 서버도 같은 규칙으로 거절한다.
|
||||
* - 형식: 확장자와 MIME 이 PNG · JPG · GIF 짝 (FILE_001)
|
||||
* - 크기: 300KB 이하 (FILE_002)
|
||||
* - 개수: 서명 칸에 이미 있는 서로 다른 그림 + 올리는 중인 그림(occupied) + 이 그림이 3장 이하 (VAL_001 — 서명 저장의 한도)
|
||||
* 붙여 넣은 그림은 이름이 없을 수 있다 — 그때는 MIME 만 본다 (올릴 때 이름을 붙인다)
|
||||
*/
|
||||
export function precheckSignatureImage(file: SignatureImageCandidate, occupied: number): ErrorCode | null {
|
||||
const extension = extensionOf(file.name)
|
||||
const byExtension = extension === '' ? null : (SIGNATURE_IMAGE_TYPES_BY_EXTENSION[extension] ?? null)
|
||||
if (!SIGNATURE_IMAGE_TYPES.includes(file.type) || (extension !== '' && byExtension !== file.type)) {
|
||||
return 'FILE_001'
|
||||
}
|
||||
if (file.size > SIGNATURE_IMAGE_MAX_BYTES) return 'FILE_002'
|
||||
if (occupied >= SIGNATURE_IMAGE_MAX_COUNT) return 'VAL_001'
|
||||
return null
|
||||
}
|
||||
|
||||
/** 이름 없이 붙여 넣은 그림의 이름 */
|
||||
const SIGNATURE_IMAGE_DEFAULT_NAME = '서명 그림'
|
||||
|
||||
/**
|
||||
* 올릴 때의 파일 이름 — 서버는 확장자와 MIME 이 맞아야 받는다. 확장자가 없으면(붙여 넣은 그림 등) 형식에 맞는 확장자를 붙인다.
|
||||
* 사전 검사(precheckSignatureImage)를 통과한 파일에만 쓴다 — 확장자가 있으면 이미 형식과 맞다.
|
||||
*/
|
||||
export function signatureImageUploadName(file: SignatureImageCandidate): string {
|
||||
if (extensionOf(file.name) !== '') return file.name
|
||||
const extension =
|
||||
Object.entries(SIGNATURE_IMAGE_TYPES_BY_EXTENSION).find(([, type]) => type === file.type)?.[0] ?? 'png'
|
||||
return `${file.name.trim() || SIGNATURE_IMAGE_DEFAULT_NAME}.${extension}`
|
||||
}
|
||||
|
||||
/** 원본 폭 — 그림의 원래 폭, 600px 이하 (서버가 남기는 width 상한) */
|
||||
export function originalImageWidth(naturalWidth: number): number {
|
||||
const width = Math.round(naturalWidth)
|
||||
return Math.max(1, Math.min(IMAGE_MAX_WIDTH, Number.isFinite(width) && width > 0 ? width : IMAGE_MAX_WIDTH))
|
||||
}
|
||||
|
||||
/** 크기 줄의 폭 */
|
||||
export function imageWidthFor(size: SignatureImageSize, naturalWidth: number): number {
|
||||
return size === 'original' ? originalImageWidth(naturalWidth) : SIGNATURE_IMAGE_SIZES[size]
|
||||
}
|
||||
|
||||
/** 새로 넣는 그림의 폭 — 보통(144), 원래 폭이 더 작으면 원래 폭 (작은 그림을 늘리지 않는다) */
|
||||
export function insertedImageWidth(naturalWidth: number): number {
|
||||
return Math.min(SIGNATURE_IMAGE_SIZES.medium, originalImageWidth(naturalWidth))
|
||||
}
|
||||
|
||||
/** 지금 폭이 크기 줄의 어느 칸인가 — 작게 · 보통 · 원본 순으로 먼저 맞는 칸, 어디에도 맞지 않으면 null */
|
||||
export function imageSizeOf(width: number | null, naturalWidth: number | null): SignatureImageSize | null {
|
||||
if (width === null) return naturalWidth === null ? null : 'original'
|
||||
if (width === SIGNATURE_IMAGE_SIZES.small) return 'small'
|
||||
if (width === SIGNATURE_IMAGE_SIZES.medium) return 'medium'
|
||||
if (naturalWidth !== null && width === originalImageWidth(naturalWidth)) return 'original'
|
||||
return null
|
||||
}
|
||||
@@ -12,13 +12,19 @@ import {
|
||||
MAIL_BODY_FONT_SIZE_PX,
|
||||
MAIL_BODY_LINE_HEIGHT,
|
||||
} from '@/shared/constants/mail-editor'
|
||||
import { STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
|
||||
import { IMAGE_SAMPLES, SAMPLE_IMAGE_ID, STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
|
||||
import {
|
||||
acceptStyleValue,
|
||||
cellSpanOf,
|
||||
imageAltOf,
|
||||
imageWidthOf,
|
||||
isOutgoingImageAttribute,
|
||||
isOutgoingStyleValue,
|
||||
lengthToPx,
|
||||
listStartOf,
|
||||
normalizeSignatureImageSrc,
|
||||
signatureImageIdOf,
|
||||
signatureImageSrc,
|
||||
} from './outgoing-html-rules'
|
||||
|
||||
describe('isOutgoingStyleValue — 서버 정화 허용 목록과 같은 값 형식 (계약 2절 "서식 본문")', () => {
|
||||
@@ -119,3 +125,33 @@ describe('숫자 속성 — 서버가 남기는 1~4자리 숫자', () => {
|
||||
for (const value of ['0', '2;', 'abc', '', null, -1, 10_000]) expect(cellSpanOf(value)).toBe(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('그림 — 서명 그림만 남는다 (계약 2절 "서식 본문"의 그림 예외, 서버와 같은 표본)', () => {
|
||||
it.each(IMAGE_SAMPLES)('%s: %s → 남는다 %s', (attribute, value, kept) => {
|
||||
expect(isOutgoingImageAttribute(attribute, value)).toBe(kept)
|
||||
})
|
||||
|
||||
it('그 밖의 속성은 남지 않는다', () => {
|
||||
for (const name of ['style', 'height', 'class', 'onerror', 'srcset', 'title']) {
|
||||
expect(isOutgoingImageAttribute(name, '1')).toBe(false)
|
||||
}
|
||||
})
|
||||
|
||||
it('주소는 서버가 다시 쓰는 모양으로 맞춘다 — 앞뒤 공백 없이, id 는 소문자', () => {
|
||||
const path = `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`
|
||||
expect(normalizeSignatureImageSrc(` /api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()} `)).toBe(path)
|
||||
expect(signatureImageIdOf(path)).toBe(SAMPLE_IMAGE_ID)
|
||||
expect(signatureImageSrc(SAMPLE_IMAGE_ID.toUpperCase())).toBe(path)
|
||||
expect(signatureImageSrc('../x')).toBeNull()
|
||||
expect(normalizeSignatureImageSrc(42)).toBeNull()
|
||||
})
|
||||
|
||||
it('폭은 숫자로, 설명은 그대로 — 형식이 틀리면 null (그 속성만 뺀다)', () => {
|
||||
expect(imageWidthOf(' 144 ')).toBe(144)
|
||||
expect(imageWidthOf(96)).toBe(96)
|
||||
expect(imageWidthOf(600.5)).toBeNull()
|
||||
expect(imageWidthOf(null)).toBeNull()
|
||||
expect(imageAltOf(' 로고 ')).toBe(' 로고 ')
|
||||
expect(imageAltOf(undefined)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
@@ -193,3 +193,67 @@ export function cellSpanOf(value: unknown): number {
|
||||
const span = smallNumber(value)
|
||||
return span === null || span < 1 ? 1 : Math.min(span, CELL_SPAN_MAX)
|
||||
}
|
||||
|
||||
// ── 그림 (계약 2절 "서식 본문" — 보내는 사람 자신의 서명 그림만 남는다) ─────────────
|
||||
// <img src="/api/mail-signature/images/{id}" width="144" alt=""> 의 src · width · alt 만 남고, 형식이 틀린 width · alt 는 그 속성만 지운다.
|
||||
// src 가 서명 그림 주소가 아니면(외부 주소 · data: · 쿼리 · 다른 경로) 그림째 지운다.
|
||||
// ⚠️ 서버 정화(backend/src/modules/mail/html/signature-images.ts 의 parseSignatureImageId · sanitize-outgoing-html.ts 의 keepSignatureImage)와
|
||||
// 같은 규칙이다 — src · width 는 앞뒤 공백을 떼고 보고, id 는 대소문자를 가리지 않고 소문자로 맞춘다. alt 는 적힌 그대로 본다.
|
||||
// 같은 표본 표를 양쪽 테스트에 둔다 (test-utils/outgoing-style-samples.ts 의 IMAGE_SAMPLES).
|
||||
// 서버는 거기에 더해 그 id 가 보내는 사람의 그림인지 본다 — 편집기는 모르므로, 남의(또는 지운) 그림은 편집기에 깨진 그림으로 보이고 보낼 때 빠진다.
|
||||
|
||||
/** 서명 그림 주소의 앞부분 — 그림 주소는 이 뒤에 id 하나만 붙는다 (편집기 · 미리 보기가 이 주소로 그림을 받는다) */
|
||||
export const SIGNATURE_IMAGE_PATH = '/api/mail-signature/images/'
|
||||
/** 서명 그림 주소 — id 는 서버가 만든 UUID (쿼리 · 조각 · 하위 경로 없이). 대소문자는 가리지 않는다 */
|
||||
const SIGNATURE_IMAGE_SRC =
|
||||
/^\/api\/mail-signature\/images\/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})$/i
|
||||
/** 그림 폭 — 1~600 의 정수, 앞자리 0 · 단위 · 소수 없이 */
|
||||
const IMAGE_WIDTH = /^(?:[1-9]\d?|[1-5]\d\d|600)$/
|
||||
/** 그림 설명(alt) 최대 길이 — JS 문자열 길이 */
|
||||
export const IMAGE_ALT_MAX_LENGTH = 200
|
||||
/** 그림 폭 상한(px) — 원본이 더 커도 이 폭으로 줄여 넣는다 */
|
||||
export const IMAGE_MAX_WIDTH = 600
|
||||
|
||||
/** 그림 주소 → 서명 그림 id (소문자). 서명 그림 주소가 아니면 null */
|
||||
export function signatureImageIdOf(src: unknown): string | null {
|
||||
if (typeof src !== 'string') return null
|
||||
const match = SIGNATURE_IMAGE_SRC.exec(src.trim())
|
||||
return match?.[1] ? match[1].toLowerCase() : null
|
||||
}
|
||||
|
||||
/** 남는 그림 주소인가 — 보내는 사람 자신의 서명 그림 주소 형식 */
|
||||
export function isSignatureImageSrc(value: unknown): value is string {
|
||||
return signatureImageIdOf(value) !== null
|
||||
}
|
||||
|
||||
/** 서명 그림 id → 그림 주소 (id 형식이 틀리면 null) */
|
||||
export function signatureImageSrc(id: string): string | null {
|
||||
const normalized = signatureImageIdOf(`${SIGNATURE_IMAGE_PATH}${id}`)
|
||||
return normalized === null ? null : `${SIGNATURE_IMAGE_PATH}${normalized}`
|
||||
}
|
||||
|
||||
/** 남는 그림 주소 — 서버가 다시 쓰는 모양(앞뒤 공백 없이 · id 소문자)으로. 서명 그림 주소가 아니면 null */
|
||||
export function normalizeSignatureImageSrc(src: unknown): string | null {
|
||||
const id = signatureImageIdOf(src)
|
||||
return id === null ? null : `${SIGNATURE_IMAGE_PATH}${id}`
|
||||
}
|
||||
|
||||
/** 남는 그림 폭 — 1~600 정수(숫자 또는 숫자 글자, 앞뒤 공백은 뗀다)가 아니면 null (폭 속성을 두지 않는다) */
|
||||
export function imageWidthOf(value: unknown): number | null {
|
||||
const text = typeof value === 'number' ? String(value) : value
|
||||
if (typeof text !== 'string' || !IMAGE_WIDTH.test(text.trim())) return null
|
||||
return Number(text.trim())
|
||||
}
|
||||
|
||||
/** 남는 그림 설명 — 200자를 넘거나 글자가 아니면 null (설명 속성을 두지 않는다) */
|
||||
export function imageAltOf(value: unknown): string | null {
|
||||
return typeof value === 'string' && value.length <= IMAGE_ALT_MAX_LENGTH ? value : null
|
||||
}
|
||||
|
||||
/** 그림 속성 하나가 남는가 — 표본 표(test-utils/outgoing-style-samples.ts 의 IMAGE_SAMPLES)로 서버와 맞춰 본다 */
|
||||
export function isOutgoingImageAttribute(name: string, value: string): boolean {
|
||||
if (name === 'src') return isSignatureImageSrc(value)
|
||||
if (name === 'width') return imageWidthOf(value) !== null
|
||||
if (name === 'alt') return imageAltOf(value) !== null
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -0,0 +1,120 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import {
|
||||
RECIPIENT_SUGGESTION_LIMIT,
|
||||
filterRecipientSuggestions,
|
||||
hasHangulJamo,
|
||||
highlightParts,
|
||||
matchEmail,
|
||||
matchName,
|
||||
} from './recipient-match'
|
||||
|
||||
const DOYUN: RecipientSuggestion = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr', source: 'directory' }
|
||||
const DOYUN_KIM: RecipientSuggestion = { name: '김도윤', email: 'doyun.kim@arasystem.it.kr', source: 'directory' }
|
||||
const PARTNER: RecipientSuggestion = { name: '최도윤', email: 'choi@partner.example', source: 'recent' }
|
||||
const NO_NAME: RecipientSuggestion = { name: null, email: 'ops@vendor.example', source: 'recent' }
|
||||
|
||||
/** 맞은 부분 글자 */
|
||||
function matched(text: string, range: { start: number; end: number } | null): string | null {
|
||||
return range ? text.slice(range.start, range.end) : null
|
||||
}
|
||||
|
||||
describe('matchName — 이름 (대소문자 무시 · 초성 · 조합 중인 마지막 글자)', () => {
|
||||
it.each([
|
||||
['도윤', '이도윤', '도윤'],
|
||||
['이도', '이도윤', '이도'],
|
||||
['ㄷㅇ', '이도윤', '도윤'],
|
||||
['ㅇㄷㅇ', '이도윤', '이도윤'],
|
||||
['도ㅇ', '이도윤', '도윤'],
|
||||
['ㅇ도', '이도윤', '이도'],
|
||||
// 마지막 글자가 조합 중 — "윤" 을 치는 중이면 "유", 다음 글자의 ㅇ 이 받침으로 붙으면 "동"
|
||||
['이도유', '이도윤', '이도윤'],
|
||||
['이동', '이도윤', '이도윤'],
|
||||
['읻', '이도윤', '이도'],
|
||||
['닭', '달기봉', '달기'],
|
||||
['kim', 'Ara KIM', 'KIM'],
|
||||
[' 도윤 ', '이도윤', '도윤'],
|
||||
])('%s → %s 에서 "%s"', (query, name, expected) => {
|
||||
expect(matched(name, matchName(query, name))).toBe(expected)
|
||||
})
|
||||
|
||||
it.each([
|
||||
['도윤', '김아라'],
|
||||
['ㄷㅈ', '이도윤'],
|
||||
['동윤', '이도윤'],
|
||||
['이동', '이도'],
|
||||
['윤도', '이도윤'],
|
||||
['', '이도윤'],
|
||||
])('%s 는 %s 에 맞지 않는다', (query, name) => {
|
||||
expect(matchName(query, name)).toBeNull()
|
||||
})
|
||||
|
||||
it('조합 중인 글자는 마지막에만 — 가운데 글자는 그대로 맞아야 한다', () => {
|
||||
expect(matchName('유ㄴ', '윤나')).toBeNull()
|
||||
expect(matchName('윤ㄴ', '윤나')).toEqual({ start: 0, end: 2 })
|
||||
})
|
||||
|
||||
it('이모지 같은 두 칸 글자가 있어도 범위는 UTF-16 위치다', () => {
|
||||
const name = '😀이도윤'
|
||||
expect(matched(name, matchName('도윤', name))).toBe('도윤')
|
||||
})
|
||||
})
|
||||
|
||||
describe('matchEmail — 주소 (대소문자 무시)', () => {
|
||||
it('친 글자가 들어 있는 곳', () => {
|
||||
expect(matched(DOYUN.email, matchEmail('doyun', DOYUN.email))).toBe('doyun')
|
||||
expect(matched(DOYUN.email, matchEmail('ARASYSTEM', DOYUN.email))).toBe('arasystem')
|
||||
expect(matchEmail('xyz', DOYUN.email)).toBeNull()
|
||||
})
|
||||
|
||||
it('완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다 (디자인 원본 ComposeAssist)', () => {
|
||||
expect(matched(DOYUN.email, matchEmail('doyun.lee@partner.example', DOYUN.email))).toBe('doyun.lee@')
|
||||
expect(matchEmail('@partner.example', DOYUN.email)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('filterRecipientSuggestions', () => {
|
||||
const pool = [PARTNER, DOYUN, NO_NAME, DOYUN_KIM]
|
||||
|
||||
it('사내를 먼저, 그 안에서는 받은 순서대로', () => {
|
||||
const result = filterRecipientSuggestions('도윤', pool, new Set())
|
||||
expect(result.map((match) => match.suggestion.email)).toEqual([DOYUN.email, DOYUN_KIM.email, PARTNER.email])
|
||||
})
|
||||
|
||||
it('이미 넣은 주소(대소문자 무시)는 빼고, 같은 주소는 한 번만', () => {
|
||||
const result = filterRecipientSuggestions('도윤', [...pool, { ...DOYUN, source: 'recent' }], new Set([DOYUN_KIM.email]))
|
||||
expect(result.map((match) => match.suggestion.email)).toEqual([DOYUN.email, PARTNER.email])
|
||||
})
|
||||
|
||||
it('이름이 없으면 주소로만 맞춘다', () => {
|
||||
expect(filterRecipientSuggestions('vendor', pool, new Set()).map((match) => match.suggestion)).toEqual([NO_NAME])
|
||||
})
|
||||
|
||||
it('빈 글자는 아무것도 보이지 않고, 최대 8개까지', () => {
|
||||
expect(filterRecipientSuggestions(' ', pool, new Set())).toEqual([])
|
||||
const many = Array.from({ length: 12 }, (_, index): RecipientSuggestion => ({
|
||||
name: `도윤${index}`,
|
||||
email: `doyun${index}@arasystem.it.kr`,
|
||||
source: 'directory',
|
||||
}))
|
||||
expect(filterRecipientSuggestions('도윤', many, new Set())).toHaveLength(RECIPIENT_SUGGESTION_LIMIT)
|
||||
})
|
||||
})
|
||||
|
||||
describe('보조 함수', () => {
|
||||
it('highlightParts — 맞은 부분만 strong', () => {
|
||||
expect(highlightParts('이도윤', { start: 1, end: 3 })).toEqual([
|
||||
{ text: '이', strong: false },
|
||||
{ text: '도윤', strong: true },
|
||||
])
|
||||
expect(highlightParts('abc', null)).toEqual([{ text: 'abc', strong: false }])
|
||||
})
|
||||
|
||||
it('hasHangulJamo — 초성 · 홀로 쓴 모음', () => {
|
||||
expect(hasHangulJamo('ㄷㅇ')).toBe(true)
|
||||
expect(hasHangulJamo('도ㅇ')).toBe(true)
|
||||
expect(hasHangulJamo('도윤')).toBe(false)
|
||||
expect(hasHangulJamo('doyun')).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,214 @@
|
||||
// 받는 사람 자동 완성의 거르기 — 순수 함수 (디자인 README "2단계 › 받는 사람 자동 완성")
|
||||
// - 이름 · 주소에 친 글자가 들어 있으면 맞는다 (대소문자 무시). 맞는 부분은 굵게 보이도록 범위를 준다.
|
||||
// - 이름은 초성으로도 찾는다: ㄷㅇ → 이"도윤". 초성과 글자를 섞어도 된다: 도ㅇ → 이"도윤".
|
||||
// - 마지막 글자는 아직 조합 중일 수 있다 — "이도유"(윤을 치는 중) · "이동"(윤의 ㅇ 이 받침으로 붙은 순간)도 "이도윤"에 맞는다.
|
||||
// - 친 글자가 완전한 주소면 같은 아이디(@ 앞)의 주소도 보인다 — 쓰기 화면은 그때 아무것도 미리 고르지 않는다.
|
||||
// - 이미 넣은 주소는 뺀다. 순서는 받은 그대로(서버: 사내 → 최근 주고받은 주소, 주고받은 횟수 · 최근성), 최대 8개.
|
||||
|
||||
import type { RecipientSuggestion } from '@/shared/types/mail'
|
||||
import { normalizeEmail } from '@/shared/utils/mail-address'
|
||||
|
||||
/** 추천 목록에 보이는 최대 개수 */
|
||||
export const RECIPIENT_SUGGESTION_LIMIT = 8
|
||||
|
||||
/** 글자 범위 — UTF-16 위치, end 는 들지 않는다 */
|
||||
export interface MatchRange {
|
||||
start: number
|
||||
end: number
|
||||
}
|
||||
|
||||
export interface RecipientMatch {
|
||||
suggestion: RecipientSuggestion
|
||||
/** 이름에서 맞은 범위 (없으면 null) */
|
||||
name: MatchRange | null
|
||||
/** 주소에서 맞은 범위 (없으면 null) */
|
||||
email: MatchRange | null
|
||||
}
|
||||
|
||||
/** 굵게 보일 조각 — 맞은 부분만 strong */
|
||||
export interface HighlightPart {
|
||||
text: string
|
||||
strong: boolean
|
||||
}
|
||||
|
||||
// ── 한글 ─────────────────────────────────────────────────
|
||||
|
||||
const SYLLABLE_START = 0xac00
|
||||
const SYLLABLE_END = 0xd7a3
|
||||
const MEDIAL_COUNT = 21
|
||||
const FINAL_COUNT = 28
|
||||
/** 초성 — 호환 자모 */
|
||||
const INITIALS = Array.from('ㄱㄲㄴㄷㄸㄹㅁㅂㅃㅅㅆㅇㅈㅉㅊㅋㅌㅍㅎ')
|
||||
/** 받침 — 0 은 받침 없음 */
|
||||
const FINALS = ['', ...Array.from('ㄱㄲㄳㄴㄵㄶㄷㄹㄺㄻㄼㄽㄾㄿㅀㅁㅂㅄㅅㅆㅇㅈㅊㅋㅌㅍㅎ')]
|
||||
/** 겹받침을 나눈다 — 뒤 자음이 다음 글자의 초성으로 넘어가는 순간이 있다 (닭 → 달 + ㄱ) */
|
||||
const SPLIT_FINALS: Readonly<Record<string, readonly [rest: string, moved: string]>> = {
|
||||
ㄳ: ['ㄱ', 'ㅅ'],
|
||||
ㄵ: ['ㄴ', 'ㅈ'],
|
||||
ㄶ: ['ㄴ', 'ㅎ'],
|
||||
ㄺ: ['ㄹ', 'ㄱ'],
|
||||
ㄻ: ['ㄹ', 'ㅁ'],
|
||||
ㄼ: ['ㄹ', 'ㅂ'],
|
||||
ㄽ: ['ㄹ', 'ㅅ'],
|
||||
ㄾ: ['ㄹ', 'ㅌ'],
|
||||
ㄿ: ['ㄹ', 'ㅍ'],
|
||||
ㅀ: ['ㄹ', 'ㅎ'],
|
||||
ㅄ: ['ㅂ', 'ㅅ'],
|
||||
}
|
||||
|
||||
interface Syllable {
|
||||
initial: number
|
||||
medial: number
|
||||
final: number
|
||||
}
|
||||
|
||||
function syllableOf(char: string): Syllable | null {
|
||||
const code = char.codePointAt(0) ?? 0
|
||||
if (char.length !== 1 || code < SYLLABLE_START || code > SYLLABLE_END) return null
|
||||
const offset = code - SYLLABLE_START
|
||||
return {
|
||||
initial: Math.floor(offset / (MEDIAL_COUNT * FINAL_COUNT)),
|
||||
medial: Math.floor((offset % (MEDIAL_COUNT * FINAL_COUNT)) / FINAL_COUNT),
|
||||
final: offset % FINAL_COUNT,
|
||||
}
|
||||
}
|
||||
|
||||
function composeSyllable(syllable: Syllable): string {
|
||||
return String.fromCharCode(
|
||||
SYLLABLE_START + (syllable.initial * MEDIAL_COUNT + syllable.medial) * FINAL_COUNT + syllable.final,
|
||||
)
|
||||
}
|
||||
|
||||
/** 초성(호환 자모) 하나인가 */
|
||||
function isInitialJamo(char: string): boolean {
|
||||
return INITIALS.includes(char)
|
||||
}
|
||||
|
||||
/** 호환 자모(ㄱ~ㅎ · ㅏ~ㅣ)가 들었는가 — 서버 검색은 초성을 모른다 */
|
||||
export function hasHangulJamo(text: string): boolean {
|
||||
return /[ㄱ-ㆎ]/.test(text)
|
||||
}
|
||||
|
||||
function initialOf(char: string): string | null {
|
||||
const syllable = syllableOf(char)
|
||||
return syllable ? (INITIALS[syllable.initial] ?? null) : null
|
||||
}
|
||||
|
||||
// ── 맞추기 ───────────────────────────────────────────────
|
||||
|
||||
/** 친 글자 하나가 이름 글자 하나에 맞는가 — 같은 글자(대소문자 무시) 또는 초성 */
|
||||
function matchesChar(typed: string, char: string): boolean {
|
||||
if (typed.toLowerCase() === char.toLowerCase()) return true
|
||||
return isInitialJamo(typed) && initialOf(char) === typed
|
||||
}
|
||||
|
||||
/**
|
||||
* 마지막으로 친 글자 — 조합 중일 수 있다. 맞으면 이름에서 쓴 글자 수(1 · 2), 아니면 0.
|
||||
* - 받침 없는 글자(유)는 같은 초성 · 중성의 글자(윤 · 율 …)에 맞는다
|
||||
* - 받침이 있는 글자(동)는 받침이 다음 글자의 초성으로 넘어갈 수 있다 — "도" + 다음 글자 초성 "ㅇ"(도윤)
|
||||
*/
|
||||
function matchesLastChar(typed: string, char: string, next: string | undefined): number {
|
||||
if (matchesChar(typed, char)) return 1
|
||||
const typedSyllable = syllableOf(typed)
|
||||
const syllable = syllableOf(char)
|
||||
if (!typedSyllable || !syllable) return 0
|
||||
if (typedSyllable.initial !== syllable.initial || typedSyllable.medial !== syllable.medial) return 0
|
||||
if (typedSyllable.final === 0) return 1
|
||||
if (next === undefined) return 0
|
||||
const finalJamo = FINALS[typedSyllable.final] ?? ''
|
||||
const [rest, moved] = SPLIT_FINALS[finalJamo] ?? ['', finalJamo]
|
||||
const restSyllable = composeSyllable({ ...typedSyllable, final: FINALS.indexOf(rest) })
|
||||
return restSyllable === char && initialOf(next) === moved ? 2 : 0
|
||||
}
|
||||
|
||||
/** chars[start] 부터 친 글자가 맞으면 맞은 끝(들지 않는 위치), 아니면 null */
|
||||
function matchFrom(typed: readonly string[], chars: readonly string[], start: number): number | null {
|
||||
let index = start
|
||||
for (let at = 0; at < typed.length; at += 1) {
|
||||
const char = chars[index]
|
||||
const typedChar = typed[at] ?? ''
|
||||
if (char === undefined) return null
|
||||
if (at === typed.length - 1) {
|
||||
const used = matchesLastChar(typedChar, char, chars[index + 1])
|
||||
return used === 0 ? null : index + used
|
||||
}
|
||||
if (!matchesChar(typedChar, char)) return null
|
||||
index += 1
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
/** 글자 단위 위치 → UTF-16 위치 */
|
||||
function utf16Offset(chars: readonly string[], index: number): number {
|
||||
return chars.slice(0, index).join('').length
|
||||
}
|
||||
|
||||
/** 이름에서 친 글자(초성 · 조합 중 포함)가 처음 맞는 범위 */
|
||||
export function matchName(query: string, name: string): MatchRange | null {
|
||||
const typed = Array.from(query.trim())
|
||||
const chars = Array.from(name)
|
||||
if (typed.length === 0) return null
|
||||
for (let start = 0; start < chars.length; start += 1) {
|
||||
const end = matchFrom(typed, chars, start)
|
||||
if (end !== null) return { start: utf16Offset(chars, start), end: utf16Offset(chars, end) }
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
/** 주소에서 친 글자가 처음 들어 있는 범위 (대소문자 무시). 완전한 주소를 쳤으면 아이디(@ 앞, @ 포함)로도 찾는다 */
|
||||
export function matchEmail(query: string, email: string): MatchRange | null {
|
||||
const typed = query.trim().toLowerCase()
|
||||
const target = email.toLowerCase()
|
||||
// 대소문자를 바꾸며 길이가 달라지는 글자(드물다)면 범위를 믿을 수 없다 — 맞추지 않는다
|
||||
if (typed === '' || target.length !== email.length) return null
|
||||
const at = target.indexOf(typed)
|
||||
if (at >= 0) return { start: at, end: at + typed.length }
|
||||
const localEnd = typed.indexOf('@')
|
||||
if (localEnd <= 0) return null
|
||||
const local = typed.slice(0, localEnd + 1)
|
||||
const localAt = target.indexOf(local)
|
||||
return localAt >= 0 ? { start: localAt, end: localAt + local.length } : null
|
||||
}
|
||||
|
||||
/** 추천 하나가 친 글자에 맞는가 — 이름 · 주소 중 하나라도 맞으면 */
|
||||
export function matchRecipient(query: string, suggestion: RecipientSuggestion): RecipientMatch | null {
|
||||
const name = suggestion.name ? matchName(query, suggestion.name) : null
|
||||
const email = matchEmail(query, suggestion.email)
|
||||
return name || email ? { suggestion, name, email } : null
|
||||
}
|
||||
|
||||
/**
|
||||
* 친 글자에 맞는 추천 — 이미 넣은 주소(excluded, 정규화한 주소)는 빼고, 같은 주소는 처음 것만, 사내를 먼저, 최대 limit 개.
|
||||
* 빈 글자면 아무것도 보이지 않는다 (한 글자 이상 쳤을 때만 목록을 연다).
|
||||
*/
|
||||
export function filterRecipientSuggestions(
|
||||
query: string,
|
||||
suggestions: readonly RecipientSuggestion[],
|
||||
excluded: ReadonlySet<string>,
|
||||
limit = RECIPIENT_SUGGESTION_LIMIT,
|
||||
): RecipientMatch[] {
|
||||
if (query.trim() === '') return []
|
||||
const seen = new Set<string>(excluded)
|
||||
const matches: RecipientMatch[] = []
|
||||
for (const suggestion of suggestions) {
|
||||
const key = normalizeEmail(suggestion.email)
|
||||
if (seen.has(key)) continue
|
||||
const match = matchRecipient(query, suggestion)
|
||||
if (!match) continue
|
||||
seen.add(key)
|
||||
matches.push(match)
|
||||
}
|
||||
const directory = matches.filter((match) => match.suggestion.source === 'directory')
|
||||
const recent = matches.filter((match) => match.suggestion.source !== 'directory')
|
||||
return [...directory, ...recent].slice(0, limit)
|
||||
}
|
||||
|
||||
/** 굵게 보일 조각으로 나눈다 */
|
||||
export function highlightParts(text: string, range: MatchRange | null): HighlightPart[] {
|
||||
if (!range || range.start >= range.end) return [{ text, strong: false }]
|
||||
return [
|
||||
{ text: text.slice(0, range.start), strong: false },
|
||||
{ text: text.slice(range.start, range.end), strong: true },
|
||||
{ text: text.slice(range.end), strong: false },
|
||||
].filter((part) => part.text !== '')
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import { nextTick } from 'vue'
|
||||
|
||||
import type { MailSignature } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { fail, installMockApi, ok, restoreApiAdapter, type MockResult } from '@/test-utils/mock-api'
|
||||
import { useMailSignatureStore } from './mail-signature.store'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const SIGNATURE: MailSignature = {
|
||||
bodyHtml: '<p>김아라</p>',
|
||||
body: '김아라',
|
||||
isForNew: true,
|
||||
isForReply: false,
|
||||
images: [],
|
||||
updatedAt: '2026-10-27T05:00:00Z',
|
||||
}
|
||||
|
||||
describe('mail-signature.store (계약 2절 "서명")', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
it('쓰기 화면은 받아 둔 서명을 쓴다 — 받는 중이면 그 결과를 기다리고, 다시 묻지 않는다', async () => {
|
||||
const requests = installMockApi(() => ok(SIGNATURE))
|
||||
const store = useMailSignatureStore()
|
||||
|
||||
const [first, second] = await Promise.all([store.ensureSignature(), store.ensureSignature()])
|
||||
await store.ensureSignature()
|
||||
|
||||
expect(first).toEqual(SIGNATURE)
|
||||
expect(second).toEqual(SIGNATURE)
|
||||
expect(requests).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('못 받으면 알리지 않고 null — 다음에 다시 받아 본다', async () => {
|
||||
let result: MockResult = fail(503, 'MAIL_001')
|
||||
const requests = installMockApi(() => result)
|
||||
const store = useMailSignatureStore()
|
||||
|
||||
await expect(store.ensureSignature()).resolves.toBeNull()
|
||||
expect(store.status).toBe('error')
|
||||
expect(store.error?.code).toBe('MAIL_001')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
result = ok(SIGNATURE)
|
||||
await expect(store.ensureSignature()).resolves.toEqual(SIGNATURE)
|
||||
expect(requests).toHaveLength(2)
|
||||
})
|
||||
|
||||
it('저장한 값이 곧 다음 쓰기 화면의 서명이다 — 사용자가 바뀌면 비운다', async () => {
|
||||
const requests = installMockApi((request) => (request.method === 'PUT' ? ok({ ...SIGNATURE, isForReply: true }) : ok(SIGNATURE)))
|
||||
const store = useMailSignatureStore()
|
||||
|
||||
await store.save({ bodyHtml: '<p>김아라</p>', body: '김아라', isForNew: true, isForReply: true })
|
||||
await expect(store.ensureSignature()).resolves.toMatchObject({ isForReply: true })
|
||||
expect(requests.map((request) => request.method)).toEqual(['PUT'])
|
||||
|
||||
useAuthStore().clearAuth()
|
||||
await nextTick()
|
||||
expect(store.signature).toBeNull()
|
||||
expect(store.status).toBe('idle')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { ref, watch } from 'vue'
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
import { toApiError, type ApiError } from '@/composables/useApi'
|
||||
import { useMail, type MailRequestOptions } from '@/composables/useMail'
|
||||
import type { MailSignature, SaveSignaturePayload } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import type { LoadStatus } from '@/stores/mail.store'
|
||||
|
||||
// 메일 서명 스토어 — 계약 2절 "서명" (사용자마다 하나)
|
||||
// - 쓰기 화면은 열 때 서명을 넣는다 — 메일 화면에 들어올 때 미리 받아 두고(ensureSignature), 쓰기 화면은 그 값을 쓴다.
|
||||
// 못 받았으면 서명 없이 연다 (알리지 않는다 — 서명은 쓰기를 막을 일이 아니다)
|
||||
// - 설정 화면은 들어올 때마다 새로 받는다(load) — 다른 창에서 고쳤을 수 있다. 오류는 화면이 직접 그린다
|
||||
// - 저장(save)한 값이 곧 다음 쓰기 화면의 서명이다
|
||||
export const useMailSignatureStore = defineStore('mail-signature', () => {
|
||||
const mailApi = useMail()
|
||||
const authStore = useAuthStore()
|
||||
|
||||
const signature = ref<MailSignature | null>(null)
|
||||
const status = ref<LoadStatus>('idle')
|
||||
const error = ref<ApiError | null>(null)
|
||||
|
||||
let seq = 0
|
||||
let pending: Promise<MailSignature | null> | null = null
|
||||
|
||||
/** 서명을 새로 받는다 — 실패하면 null (알림 없이 상태에 담는다) */
|
||||
function load(): Promise<MailSignature | null> {
|
||||
seq += 1
|
||||
const current = seq
|
||||
status.value = 'loading'
|
||||
error.value = null
|
||||
const request = (async () => {
|
||||
try {
|
||||
const result = await mailApi.getSignature({ silent: true })
|
||||
if (current !== seq) return signature.value
|
||||
signature.value = result
|
||||
status.value = 'ready'
|
||||
return result
|
||||
} catch (caught) {
|
||||
if (current !== seq) return signature.value
|
||||
status.value = 'error'
|
||||
error.value = toApiError(caught)
|
||||
return null
|
||||
} finally {
|
||||
if (current === seq) pending = null
|
||||
}
|
||||
})()
|
||||
pending = request
|
||||
return request
|
||||
}
|
||||
|
||||
/** 받아 둔 서명 — 아직 없거나 실패했으면 받는다 (받는 중이면 그 결과를 기다린다) */
|
||||
function ensureSignature(): Promise<MailSignature | null> {
|
||||
if (status.value === 'ready') return Promise.resolve(signature.value)
|
||||
if (pending) return pending
|
||||
return load()
|
||||
}
|
||||
|
||||
/** 서명 저장 — 실패하면 예외가 그대로 올라간다 (칸 안 안내는 화면이 한다) */
|
||||
async function save(payload: SaveSignaturePayload, options?: MailRequestOptions): Promise<MailSignature> {
|
||||
const result = await mailApi.saveSignature(payload, options)
|
||||
seq += 1
|
||||
pending = null
|
||||
signature.value = result
|
||||
status.value = 'ready'
|
||||
error.value = null
|
||||
return result
|
||||
}
|
||||
|
||||
function reset() {
|
||||
seq += 1
|
||||
pending = null
|
||||
signature.value = null
|
||||
status.value = 'idle'
|
||||
error.value = null
|
||||
}
|
||||
|
||||
// 로그아웃 · 다른 계정 로그인 — 이전 사용자의 서명이 남지 않게
|
||||
watch(
|
||||
() => authStore.user?.email ?? null,
|
||||
() => reset(),
|
||||
)
|
||||
|
||||
return { signature, status, error, load, ensureSignature, save, reset }
|
||||
})
|
||||
@@ -249,6 +249,35 @@ describe('mail.store', () => {
|
||||
])
|
||||
})
|
||||
|
||||
it('다른 메일함으로 옮긴다(되돌리기) — 개수를 다시 맞추고, 보이는 목록이 옮긴 곳 · 휴지통이면 새로 고친다', async () => {
|
||||
const DRAFTS: Mailbox = { ...INBOX, id: 'drafts', name: '임시보관함', role: 'drafts', unreadCount: 0 }
|
||||
let patchResult: MockResult = ok(summary('d1', { mailboxIds: ['drafts'] }))
|
||||
const requests = installMockApi(({ method, url }) => {
|
||||
if (method === 'PATCH') return patchResult
|
||||
if (url === '/mailboxes') {
|
||||
return ok({ items: [INBOX, DRAFTS, TRASH], pageInfo: { hasNext: false, nextCursor: null, total: 3 } })
|
||||
}
|
||||
if (url === '/mail-quota') return ok({ usedBytes: 0, limitBytes: null, attachmentMaxBytes: 1, attachmentTotalMaxBytes: 1 })
|
||||
return page([summary('m1')])
|
||||
})
|
||||
const store = useMailStore()
|
||||
await store.loadMailboxes()
|
||||
await store.openMailbox('trash')
|
||||
const listsBefore = requests.filter((request) => request.url === '/messages').length
|
||||
|
||||
await expect(store.moveMessage('d1', 'drafts')).resolves.toBe(true)
|
||||
await flushPromises()
|
||||
|
||||
const patch = requests.find((request) => request.method === 'PATCH')
|
||||
expect(patch?.url).toBe('/messages/d1')
|
||||
expect(patch?.data).toEqual({ mailboxId: 'drafts' })
|
||||
expect(requests.filter((request) => request.url === '/messages').length).toBe(listsBefore + 1)
|
||||
expect(requests.filter((request) => request.url === '/mailboxes').length).toBe(2)
|
||||
|
||||
patchResult = fail(404, 'RES_001')
|
||||
await expect(store.moveMessage('d1', 'drafts')).resolves.toBe(false)
|
||||
})
|
||||
|
||||
describe('사용량 (GET /mail-quota)', () => {
|
||||
const QUOTA = {
|
||||
usedBytes: 966_367_641,
|
||||
@@ -313,6 +342,22 @@ describe('mail.store', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('다시 저장해 바뀐 임시 저장본 id 를 끝까지 따라간다 — 모르는 id 는 그대로, 사용자가 바뀌면 잊는다', async () => {
|
||||
const store = useMailStore()
|
||||
store.recordDraftSuccessor('d1', 'd2')
|
||||
store.recordDraftSuccessor('d2', 'd3')
|
||||
store.recordDraftSuccessor('d3', 'd3')
|
||||
|
||||
expect(store.latestDraftId('d1')).toBe('d3')
|
||||
expect(store.latestDraftId('d2')).toBe('d3')
|
||||
expect(store.latestDraftId('d3')).toBe('d3')
|
||||
expect(store.latestDraftId('other')).toBe('other')
|
||||
|
||||
useAuthStore().expireSession()
|
||||
await nextTick()
|
||||
expect(store.latestDraftId('d1')).toBe('d1')
|
||||
})
|
||||
|
||||
it('로그아웃·세션 만료로 사용자가 바뀌면 이전 사용자의 메일을 비운다', async () => {
|
||||
installMockApi(() => page([summary('m1')]))
|
||||
const store = useMailStore()
|
||||
|
||||
@@ -63,6 +63,13 @@ export const useMailStore = defineStore('mail', () => {
|
||||
/** 쓰기를 마치거나 취소하면 돌아갈 화면 (보던 메일함·메일) */
|
||||
const lastBrowseLocation = ref<string | null>(null)
|
||||
|
||||
/**
|
||||
* 이 탭에서 다시 저장해 바뀐 임시 저장본 id — 옛 id → 새 id (계약 2절 "임시 저장": 저장할 때마다 id 가 바뀌고 옛것은 지운다).
|
||||
* 방문 기록에 남은 옛 주소(?draft=<옛 id>)로 돌아오면 쓰기 화면이 마지막 저장본을 연다 — 떠나기 전 저장처럼
|
||||
* 주소를 맞추지 못한 채 떠난 경우다 (떠나는 중에 주소를 바꾸면 가던 이동이 취소된다). 화면에 그리지 않으므로 반응형이 아니다
|
||||
*/
|
||||
const draftSuccessors = new Map<string, string>()
|
||||
|
||||
let mailboxSeq = 0
|
||||
let quotaSeq = 0
|
||||
let listSeq = 0
|
||||
@@ -105,10 +112,10 @@ export const useMailStore = defineStore('mail', () => {
|
||||
let mailboxLoadsInFlight = 0
|
||||
|
||||
/**
|
||||
* 메일함 목록.
|
||||
* 메일함 목록 — 실패하면 그 오류를 돌려준다(알리지 않는다 — 부르는 쪽이 알릴지 정한다), 받았으면 null.
|
||||
* background 면 이미 보이는 화면 상태(불러오는 중·오류)를 건드리지 않고 개수만 서버 기준으로 다시 맞춘다.
|
||||
*/
|
||||
async function loadMailboxes(options: { background?: boolean } = {}): Promise<void> {
|
||||
async function loadMailboxes(options: { background?: boolean } = {}): Promise<ApiError | null> {
|
||||
mailboxSeq += 1
|
||||
const seq = mailboxSeq
|
||||
if (!options.background) {
|
||||
@@ -118,16 +125,19 @@ export const useMailStore = defineStore('mail', () => {
|
||||
mailboxLoadsInFlight += 1
|
||||
try {
|
||||
const result = await mailApi.listMailboxes({ silent: true })
|
||||
if (seq !== mailboxSeq) return
|
||||
if (seq !== mailboxSeq) return null
|
||||
mailboxes.value = result.items
|
||||
mailboxesStatus.value = 'ready'
|
||||
mailboxesError.value = null
|
||||
return null
|
||||
} catch (error) {
|
||||
if (seq !== mailboxSeq) return
|
||||
const apiError = toApiError(error)
|
||||
if (seq !== mailboxSeq) return apiError
|
||||
// 이미 목록이 보이면 조용히 둔다. 첫 조회를 대신한 요청이었다면 패널에 오류를 그린다
|
||||
if (options.background && mailboxesStatus.value === 'ready') return
|
||||
if (options.background && mailboxesStatus.value === 'ready') return apiError
|
||||
mailboxesStatus.value = 'error'
|
||||
mailboxesError.value = toApiError(error)
|
||||
mailboxesError.value = apiError
|
||||
return apiError
|
||||
} finally {
|
||||
mailboxLoadsInFlight -= 1
|
||||
}
|
||||
@@ -341,10 +351,45 @@ export const useMailStore = defineStore('mail', () => {
|
||||
return next?.id ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* 메일을 다른 메일함으로 옮긴다 (PATCH { mailboxId }) — 예: 버린 임시 저장본을 임시보관함으로 되돌리기.
|
||||
* 개수를 서버 기준으로 다시 맞추고, 지금 보이는 목록이 옮긴 곳이나 휴지통이면 보이는 채로 새로 고친다.
|
||||
* 실패하면 false (알림은 인터셉터가 띄웠다).
|
||||
*/
|
||||
async function moveMessage(id: string, mailboxId: string): Promise<boolean> {
|
||||
try {
|
||||
await mailApi.updateMessage(id, { mailboxId })
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
syncCounts()
|
||||
const shown = currentMailboxId.value
|
||||
const trashId = mailboxByRole('trash')?.id ?? null
|
||||
if (shown !== null && (shown === mailboxId || shown === trashId)) void openMailbox(shown)
|
||||
return true
|
||||
}
|
||||
|
||||
function rememberBrowseLocation(fullPath: string) {
|
||||
lastBrowseLocation.value = fullPath
|
||||
}
|
||||
|
||||
/** 임시 저장본을 다시 저장해 새 id 를 받았다 — 옛 id 로 열면 새 id 를 연다 */
|
||||
function recordDraftSuccessor(previousId: string, nextId: string) {
|
||||
if (previousId !== nextId) draftSuccessors.set(previousId, nextId)
|
||||
}
|
||||
|
||||
/** 이 탭이 아는 마지막 저장본 id — 다시 저장한 적 없으면 그대로 */
|
||||
function latestDraftId(id: string): string {
|
||||
let latest = id
|
||||
// 새 id 는 늘 새것이라 고리가 생기지 않지만, 만에 하나를 위해 기록 수만큼만 따라간다
|
||||
for (let step = 0; step < draftSuccessors.size; step += 1) {
|
||||
const next = draftSuccessors.get(latest)
|
||||
if (next === undefined) break
|
||||
latest = next
|
||||
}
|
||||
return latest
|
||||
}
|
||||
|
||||
function reset() {
|
||||
mailboxSeq += 1
|
||||
quotaSeq += 1
|
||||
@@ -366,6 +411,7 @@ export const useMailStore = defineStore('mail', () => {
|
||||
selectedStatus.value = 'idle'
|
||||
selectedError.value = null
|
||||
lastBrowseLocation.value = null
|
||||
draftSuccessors.clear()
|
||||
}
|
||||
|
||||
// 로그아웃·세션 만료·다른 계정 로그인 때 이전 사용자의 메일이 화면에 남지 않게 비운다
|
||||
@@ -411,7 +457,10 @@ export const useMailStore = defineStore('mail', () => {
|
||||
markRead,
|
||||
toggleFlag,
|
||||
removeMessage,
|
||||
moveMessage,
|
||||
rememberBrowseLocation,
|
||||
recordDraftSuccessor,
|
||||
latestDraftId,
|
||||
reset,
|
||||
}
|
||||
})
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import ToastHost from '@/components/feedback/ToastHost.vue'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import { TOAST_ACTION_DURATION_MS, TOAST_DURATION_MS, useToastStore } from './toast.store'
|
||||
|
||||
// 알림이 닫히는 시간 — 단추가 있는 알림은 10초, 마우스 · 초점이 안에 있는 동안은 닫지 않는다 (WCAG 2.2.1)
|
||||
|
||||
describe('toast.store', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
useToastStore().clear()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
const messages = () => useToastStore().toasts.map((toast) => toast.message)
|
||||
const undo = { label: '되돌리기', run: () => undefined }
|
||||
|
||||
it('단추 없는 알림은 종류별 시간, 단추가 있는 알림은 10초 보인다', () => {
|
||||
const store = useToastStore()
|
||||
store.show({ kind: 'success', message: '보냈습니다' })
|
||||
store.show({ kind: 'success', message: '옮겼습니다', action: undo })
|
||||
|
||||
vi.advanceTimersByTime(TOAST_DURATION_MS.success)
|
||||
expect(messages()).toEqual(['옮겼습니다'])
|
||||
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS - TOAST_DURATION_MS.success - 1)
|
||||
expect(messages()).toEqual(['옮겼습니다'])
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(messages()).toEqual([])
|
||||
})
|
||||
|
||||
it('붙잡은 동안은 닫지 않고, 까닭이 모두 사라지면 시간을 처음부터 다시 센다', () => {
|
||||
const store = useToastStore()
|
||||
const id = store.show({ kind: 'success', message: '옮겼습니다', action: undo })
|
||||
|
||||
vi.advanceTimersByTime(9_000)
|
||||
store.hold(id, 'pointer')
|
||||
store.hold(id, 'focus')
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(messages()).toEqual(['옮겼습니다'])
|
||||
|
||||
store.release(id, 'pointer')
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(messages()).toEqual(['옮겼습니다'])
|
||||
|
||||
store.release(id, 'focus')
|
||||
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS - 1)
|
||||
expect(messages()).toEqual(['옮겼습니다'])
|
||||
vi.advanceTimersByTime(1)
|
||||
expect(messages()).toEqual([])
|
||||
})
|
||||
|
||||
it('ToastHost — 마우스가 올라가 있거나 초점이 안에 있는 동안은 닫히지 않는다 (알림 안에서 옮긴 초점은 그대로)', async () => {
|
||||
const router = createTestRouter()
|
||||
const wrapper = mount(ToastHost, { attachTo: document.body, global: { plugins: [pinia, router] } })
|
||||
useToastStore().show({ kind: 'success', message: '옮겼습니다', action: undo })
|
||||
await wrapper.vm.$nextTick()
|
||||
const toast = wrapper.get('.toast')
|
||||
|
||||
await toast.trigger('mouseenter')
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(messages()).toEqual(['옮겼습니다'])
|
||||
await toast.trigger('focusin')
|
||||
await toast.trigger('mouseleave')
|
||||
// 알림 안의 단추로 초점이 옮겨 갔다 — 놓지 않는다
|
||||
await toast.trigger('focusout', { relatedTarget: toast.get('.toast__action').element })
|
||||
vi.advanceTimersByTime(60_000)
|
||||
expect(messages()).toEqual(['옮겼습니다'])
|
||||
|
||||
await toast.trigger('focusout', { relatedTarget: document.body })
|
||||
vi.advanceTimersByTime(TOAST_ACTION_DURATION_MS)
|
||||
expect(messages()).toEqual([])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -4,13 +4,13 @@ import type { RouteLocationRaw } from 'vue-router'
|
||||
|
||||
// 화면 오른쪽 아래 알림(토스트) 상태 — ToastHost.vue 가 그린다.
|
||||
// 오류 알림은 useApi 의 showErrorUI() 만 띄운다 (같은 코드 3초 중복 억제는 그쪽 책임).
|
||||
// 마우스가 올라가 있거나 초점이 알림 안에 있는 동안은 닫지 않는다(hold · release) — 다 떠나면 그 알림의 시간을 처음부터 다시 센다
|
||||
// (WCAG 2.2.1 시간 조절 — 단추를 누르려는 사람이 시간에 쫓기지 않게).
|
||||
|
||||
export type ToastKind = 'error' | 'success'
|
||||
|
||||
export interface ToastAction {
|
||||
label: string
|
||||
to: RouteLocationRaw
|
||||
}
|
||||
/** 알림의 단추 — 다른 화면으로 가는 링크(to) 또는 그 자리에서 하는 일(run, 예: 되돌리기). 누르면 알림을 닫는다 */
|
||||
export type ToastAction = { label: string; to: RouteLocationRaw } | { label: string; run: () => void }
|
||||
|
||||
export interface Toast {
|
||||
id: number
|
||||
@@ -38,9 +38,18 @@ export const TOAST_DURATION_MS: Readonly<Record<ToastKind, number>> = {
|
||||
success: 5000,
|
||||
}
|
||||
|
||||
/** 단추가 있는 알림(되돌리기 · 보낸편지함 보기 등)이 닫히기까지 — 단추를 찾아 누를 시간을 넉넉히 둔다 */
|
||||
export const TOAST_ACTION_DURATION_MS = 10_000
|
||||
|
||||
/** 알림을 붙잡아 두는 까닭 — 마우스가 올라가 있다 · 초점이 안에 있다 */
|
||||
export type ToastHoldReason = 'pointer' | 'focus'
|
||||
|
||||
export const useToastStore = defineStore('toast', () => {
|
||||
const toasts = ref<Toast[]>([])
|
||||
const timers = new Map<number, ReturnType<typeof setTimeout>>()
|
||||
/** 알림마다 닫히기까지의 시간 — 붙잡았다 놓으면 이 시간을 다시 센다 */
|
||||
const durations = new Map<number, number>()
|
||||
const holds = new Map<number, Set<ToastHoldReason>>()
|
||||
let nextId = 1
|
||||
|
||||
function stopTimer(id: number) {
|
||||
@@ -49,11 +58,45 @@ export const useToastStore = defineStore('toast', () => {
|
||||
timers.delete(id)
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
function startTimer(id: number) {
|
||||
stopTimer(id)
|
||||
const duration = durations.get(id)
|
||||
if (duration === undefined) return
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => dismiss(id), duration),
|
||||
)
|
||||
}
|
||||
|
||||
/** 알림을 잊는다 — 타이머 · 붙잡은 까닭까지 */
|
||||
function forget(id: number) {
|
||||
stopTimer(id)
|
||||
durations.delete(id)
|
||||
holds.delete(id)
|
||||
}
|
||||
|
||||
function dismiss(id: number) {
|
||||
forget(id)
|
||||
toasts.value = toasts.value.filter((toast) => toast.id !== id)
|
||||
}
|
||||
|
||||
/** 붙잡는다 — 마우스가 올라왔다 · 초점이 들어왔다. 그동안은 닫지 않는다 */
|
||||
function hold(id: number, reason: ToastHoldReason) {
|
||||
if (!durations.has(id)) return
|
||||
const reasons = holds.get(id) ?? new Set<ToastHoldReason>()
|
||||
reasons.add(reason)
|
||||
holds.set(id, reasons)
|
||||
stopTimer(id)
|
||||
}
|
||||
|
||||
/** 놓는다 — 붙잡은 까닭이 모두 사라지면 그 알림의 시간을 처음부터 다시 센다 */
|
||||
function release(id: number, reason: ToastHoldReason) {
|
||||
const reasons = holds.get(id)
|
||||
if (!reasons?.delete(reason) || reasons.size > 0) return
|
||||
holds.delete(id)
|
||||
startTimer(id)
|
||||
}
|
||||
|
||||
function show(input: ToastInput): number {
|
||||
const id = nextId
|
||||
nextId += 1
|
||||
@@ -67,21 +110,21 @@ export const useToastStore = defineStore('toast', () => {
|
||||
}
|
||||
const all = [...toasts.value, next]
|
||||
const overflow = all.slice(0, Math.max(0, all.length - MAX_VISIBLE_TOASTS))
|
||||
overflow.forEach((toast) => stopTimer(toast.id))
|
||||
overflow.forEach((toast) => forget(toast.id))
|
||||
toasts.value = all.slice(-MAX_VISIBLE_TOASTS)
|
||||
|
||||
timers.set(
|
||||
id,
|
||||
setTimeout(() => dismiss(id), TOAST_DURATION_MS[input.kind]),
|
||||
)
|
||||
durations.set(id, next.action ? TOAST_ACTION_DURATION_MS : TOAST_DURATION_MS[input.kind])
|
||||
startTimer(id)
|
||||
return id
|
||||
}
|
||||
|
||||
function clear() {
|
||||
timers.forEach((timer) => clearTimeout(timer))
|
||||
timers.clear()
|
||||
durations.clear()
|
||||
holds.clear()
|
||||
toasts.value = []
|
||||
}
|
||||
|
||||
return { toasts, show, dismiss, clear }
|
||||
return { toasts, show, dismiss, hold, release, clear }
|
||||
})
|
||||
|
||||
@@ -63,6 +63,35 @@ a:hover {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/*
|
||||
* 둘 · 셋 중 하나를 고르는 전환 단추 — 설정 화면의 그림 크기 · 미리 볼 메일 (디자인 원본 Settings 의 .seg)
|
||||
* 회색 바탕 묶음 안에서 고른 칸만 흰 바탕 + 그림자. 단추 높이 36 — 캔버스 확정값, 44px 기준의 밀도 예외(--size-segment-item-lg).
|
||||
* 사용: <div role="group" class="segmented"><button class="segmented__item" :aria-pressed="…">…</button></div>
|
||||
*/
|
||||
.segmented {
|
||||
display: inline-flex;
|
||||
gap: var(--space-2);
|
||||
padding: var(--space-2);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--color-surface-muted);
|
||||
}
|
||||
|
||||
.segmented__item {
|
||||
min-height: var(--size-segment-item-lg);
|
||||
padding: 0 var(--space-12);
|
||||
border: 0;
|
||||
border-radius: var(--radius-sm);
|
||||
background: transparent;
|
||||
color: var(--color-text-secondary);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.segmented__item[aria-pressed='true'] {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
box-shadow: var(--shadow-segment);
|
||||
}
|
||||
|
||||
/* 화면에는 숨기고 스크린리더에는 읽힌다 (안 읽음·준비 중 같은 상태 전달용) */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
@@ -22,6 +22,7 @@
|
||||
--font-size-body: 0.875rem; /* 14px — 본문·입력 */
|
||||
--font-size-title-sm: 0.9375rem; /* 15px — 영역 제목 */
|
||||
--font-size-title: 1rem; /* 16px — 상단 바 모듈 이름 */
|
||||
--font-size-title-lg: 1.125rem; /* 18px — 설정 화면 제목 (디자인 원본 Settings) */
|
||||
--font-size-heading: 1.25rem; /* 20px — 메일 제목 */
|
||||
--font-size-heading-lg: 1.375rem; /* 22px — 로그인 제목 */
|
||||
--font-size-display: 1.875rem; /* 30px — 로그인 소개 문구 */
|
||||
@@ -73,7 +74,8 @@
|
||||
--color-danger-surface: #fef3f2;
|
||||
--color-danger-border: #fbd7d3;
|
||||
--color-warning-surface: #fff7e6;
|
||||
--color-warning-text: #7a5200;
|
||||
--color-warning-text: #7a5200; /* 임시 저장 "다른 곳에서 바뀐 저장본" 안내 */
|
||||
--color-success-text: #067647; /* 서명 "저장했습니다" (디자인 원본 Settings) */
|
||||
--color-star: #b7791f;
|
||||
|
||||
/* ── 간격 (이름의 숫자 = px) ─────────────── */
|
||||
@@ -116,16 +118,19 @@
|
||||
--shadow-toast: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.12);
|
||||
--shadow-toast-strong: 0 0.625rem 1.75rem rgba(16, 24, 28, 0.22);
|
||||
--shadow-dialog: 0 0.875rem 2.25rem rgba(16, 24, 28, 0.14);
|
||||
--shadow-segment: 0 0.0625rem 0.125rem rgba(16, 24, 28, 0.14); /* 전환 단추의 고른 칸 (디자인 원본 Settings) */
|
||||
--color-backdrop: rgba(11, 54, 64, 0.32);
|
||||
|
||||
/* ── 크기 ───────────────────────────────── */
|
||||
--size-touch: 2.75rem; /* 44px — 누를 수 있는 최소 영역 */
|
||||
/*
|
||||
* 밀도를 위한 작은 조작 영역 — 클릭 영역 44px 기준의 의도된 예외 (디자인 README "디자인 원칙", 사용자 확정).
|
||||
* 목록 표의 별 버튼(폭 32px · 행 높이를 채움)과 "분할 | 목록" 전환(바깥 36px)에만 쓴다.
|
||||
* 목록 표의 별 버튼(폭 32px · 행 높이를 채움), "분할 | 목록" 전환(바깥 36px),
|
||||
* 설정 화면의 전환 단추 · 서명 그림의 크기 줄(작게 · 보통 · 원본 · 그림 빼기 — 36px, 캔버스 확정값)에만 쓴다.
|
||||
*/
|
||||
--size-compact-control: 2rem; /* 32px */
|
||||
--size-segment-item: 1.875rem; /* 30px — 전환 버튼 한 칸 높이 (테두리·안쪽 여백 포함 36px) */
|
||||
--size-segment-item-lg: 2.25rem; /* 36px — 밀도 예외: 설정 화면의 전환 단추(그림 크기 · 미리 볼 메일) · 그림 빼기 (디자인 원본 Settings) */
|
||||
--size-icon-button: 2.5rem; /* 40px — 새로고침 · 이전/다음 버튼 폭 (높이는 44px) */
|
||||
--size-rail: 4.5rem; /* 72px — 앱 레일 폭 */
|
||||
--size-rail-item: 3.75rem; /* 60px */
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
// 단위 테스트 전용 — 손으로 돌리는 시계. advance(ms) 를 부를 때만 시간이 흐르고 그 사이에 닿는 타이머를 차례로 부른다.
|
||||
// 화면에는 CLOCK 으로 provide 한다: mount(Page, { global: { provide: { [CLOCK as symbol]: clock } } })
|
||||
// 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
import type { Clock, ClockTimer } from '@/composables/useClock'
|
||||
|
||||
interface PendingTimer {
|
||||
id: symbol
|
||||
due: number
|
||||
/** 같은 시각이면 먼저 건 것부터 */
|
||||
order: number
|
||||
callback: () => void
|
||||
}
|
||||
|
||||
export interface ManualClock extends Clock {
|
||||
/** 시간을 ms 만큼 넘긴다 — 그 사이에 닿는 타이머(넘기는 동안 새로 건 것 포함)를 시각 순서대로 부른다 */
|
||||
advance: (ms: number) => void
|
||||
/** 아직 부르지 않은 타이머 수 */
|
||||
pendingCount: () => number
|
||||
}
|
||||
|
||||
export function createManualClock(start = 0): ManualClock {
|
||||
let current = start
|
||||
let nextOrder = 0
|
||||
const timers: PendingTimer[] = []
|
||||
|
||||
function nextDue(limit: number): PendingTimer | null {
|
||||
let found: PendingTimer | null = null
|
||||
for (const timer of timers) {
|
||||
if (timer.due > limit) continue
|
||||
if (!found || timer.due < found.due || (timer.due === found.due && timer.order < found.order)) found = timer
|
||||
}
|
||||
return found
|
||||
}
|
||||
|
||||
return {
|
||||
now: () => current,
|
||||
setTimeout: (callback, ms) => {
|
||||
const timer: PendingTimer = {
|
||||
id: Symbol('timer'),
|
||||
due: current + Math.max(0, ms),
|
||||
order: nextOrder,
|
||||
callback,
|
||||
}
|
||||
nextOrder += 1
|
||||
timers.push(timer)
|
||||
return timer.id
|
||||
},
|
||||
clearTimeout: (handle: ClockTimer) => {
|
||||
const index = timers.findIndex((timer) => timer.id === handle)
|
||||
if (index >= 0) timers.splice(index, 1)
|
||||
},
|
||||
advance: (ms) => {
|
||||
const target = current + ms
|
||||
for (let timer = nextDue(target); timer; timer = nextDue(target)) {
|
||||
timers.splice(timers.indexOf(timer), 1)
|
||||
current = Math.max(current, timer.due)
|
||||
timer.callback()
|
||||
}
|
||||
current = target
|
||||
},
|
||||
pendingCount: () => timers.length,
|
||||
}
|
||||
}
|
||||
@@ -122,3 +122,56 @@ export const STYLE_SAMPLES: readonly (readonly [property: string, value: string,
|
||||
['text-decoration', 'underline', false],
|
||||
]
|
||||
|
||||
|
||||
/** 표본 그림 id — 서버가 만드는 UUID 모양 */
|
||||
export const SAMPLE_IMAGE_ID = '3f1c0a2e-0f1b-4f2a-9a1e-6c9f0a1b2c3d'
|
||||
|
||||
/**
|
||||
* 그림(img) 속성 표본 — [속성, 값, 서버가 남기는가]. 계약 2절 "서식 본문"의 그림 예외 (서명 그림만).
|
||||
* src 가 남지 않으면 그림째 지운다. width · alt 가 남지 않으면 그 속성만 지운다.
|
||||
* ⚠️ 서버 정화(backend/src/modules/mail/html/signature-images.spec.ts · sanitize-outgoing-html.spec.ts)와 **같은 표**를 둔다.
|
||||
* 서버는 여기에 더해 id 가 보내는 사람의 그림인지 본다 (표의 id 는 보내는 사람의 그림이라고 본다).
|
||||
*/
|
||||
export const IMAGE_SAMPLES: readonly (readonly [attribute: 'src' | 'width' | 'alt', value: string, kept: boolean])[] = [
|
||||
// 주소 — 정확히 /api/mail-signature/images/{uuid} (앞뒤 공백은 떼고, 대소문자는 가리지 않는다)
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID.toUpperCase()}`, true],
|
||||
['src', ` /api/mail-signature/images/${SAMPLE_IMAGE_ID} `, true],
|
||||
['src', '', false],
|
||||
['src', '/api/mail-signature/images/', false],
|
||||
['src', '/api/mail-signature/images/not-a-uuid', false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}?x=1`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}#a`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/../x`, false],
|
||||
['src', `/api/mail-signature/images/${SAMPLE_IMAGE_ID}/`, false],
|
||||
['src', `https://portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `//portal.example/api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `api/mail-signature/images/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `/API/mail-signature/images/${SAMPLE_IMAGE_ID}`, true],
|
||||
['src', `/api/files/${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', `cid:${SAMPLE_IMAGE_ID}`, false],
|
||||
['src', 'data:image/png;base64,iVBORw0KGgo=', false],
|
||||
['src', 'https://evil.example/t.png', false],
|
||||
// 폭 — 1~600 정수 (앞뒤 공백은 뗀다)
|
||||
['width', '1', true],
|
||||
['width', '96', true],
|
||||
['width', '144', true],
|
||||
['width', '600', true],
|
||||
['width', ' 144 ', true],
|
||||
['width', '0', false],
|
||||
['width', '601', false],
|
||||
['width', '1000', false],
|
||||
['width', '096', false],
|
||||
['width', '-1', false],
|
||||
['width', '+5', false],
|
||||
['width', '14.5', false],
|
||||
['width', '144px', false],
|
||||
['width', '50%', false],
|
||||
['width', '1e2', false],
|
||||
['width', '', false],
|
||||
// 설명 — 200자 이하면 그대로 (빈 글자 포함)
|
||||
['alt', '', true],
|
||||
['alt', '회사 로고', true],
|
||||
['alt', 'ㄱ'.repeat(200), true],
|
||||
['alt', 'ㄱ'.repeat(201), false],
|
||||
]
|
||||
|
||||
@@ -28,6 +28,13 @@ export function createTestRouter(): Router {
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
{ path: '/settings', name: 'settings', redirect: { name: 'settings-mail-signature' } },
|
||||
{
|
||||
path: '/settings/mail/signature',
|
||||
name: 'settings-mail-signature',
|
||||
component: Empty,
|
||||
meta: { requiresAuth: true },
|
||||
},
|
||||
],
|
||||
})
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user