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('(준비 중)')
|
||||
|
||||
Reference in New Issue
Block a user