feat(frontend): 쓰기 화면 나가기 확인·읽기 화면 본문 안내·내 메일 답장 받는 사람

- 쓰기 화면을 연 뒤 바꾼 것이 있으면 떠나기 전에 묻는다 — 앱 안 이동은 확인 대화상자, 탭 닫기·새로고침은 브라우저 확인창.
  보낸 뒤·보내는 중·세션이 끝났을 때는 묻지 않고, 보내는 중에 떠나면 결과는 알림으로만 알린다(떠난 양식은 건드리지 않는다).
  대화상자는 공용 ModalDialog 로 모았다 — 세션 만료 대화상자도 같은 것을 쓴다
- 본문 길이 초과는 MAIL_007 문구로 본문 아래에 알린다 (서버가 같은 코드를 돌려줘도 같다, 발송 키는 그대로)
- 읽기 화면에 본문 안내(서식 없이 표시·표시할 수 없음·앞부분만 표시). 잘린 원문을 인용하면 생략 표시를 둔다.
  인용할 원문은 읽기 화면과 같은 규칙으로 고른다 — 공백뿐인 HTML·그림만 남는 HTML 은 글자 본문으로
- 붙여 넣은 글·원문 인용에서 8px 미만 글자 크기와 알파 0.3 미만 글자 색을 버린다 (서버와 같은 규칙, 같은 표본 표 105행)
- 내가 보낸 메일에 답장하면 받는 사람이 원래 받는 사람이다 (전에는 받는 사람 칸이 비었다)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-27 13:00:09 +09:00
parent fbfad8d0ca
commit bbd88e707a
39 changed files with 1912 additions and 196 deletions
@@ -0,0 +1,111 @@
import { afterEach, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { defineComponent, h, nextTick, ref } from 'vue'
import { flushPromises } from '@/test-utils/mock-api'
import ModalDialog from './ModalDialog.vue'
// jsdom 에는 showModal 이 없다 — 대화상자는 open 속성만 붙였다 뗀다 (ModalDialog 의 대체 경로)
const mounted: VueWrapper[] = []
afterEach(() => {
for (const wrapper of mounted.splice(0)) wrapper.unmount()
})
function mountDialog(open: boolean) {
const wrapper = mount(ModalDialog, {
attachTo: document.body,
props: { open, title: '보내지 않은 메일이 있습니다', message: '글', titleId: 't', messageId: 'm', role: 'alertdialog' },
slots: {
actions: () => [
h('button', { id: 'stay', type: 'button', autofocus: true }, '계속 쓰기'),
h('button', { id: 'leave', type: 'button' }, '나가기'),
],
},
})
mounted.push(wrapper)
return wrapper
}
describe('ModalDialog', () => {
it('제목 · 글을 aria 로 잇고, 열면 autofocus 단추로 · 닫으면 연 때의 자리로 초점을 옮긴다', async () => {
const before = document.createElement('button')
document.body.append(before)
before.focus()
const wrapper = mountDialog(false)
const dialog = wrapper.get('dialog')
expect(dialog.attributes()).toMatchObject({ role: 'alertdialog', 'aria-labelledby': 't', 'aria-describedby': 'm' })
expect(dialog.attributes('open')).toBeUndefined()
await wrapper.setProps({ open: true })
await flushPromises()
expect(dialog.attributes('open')).toBeDefined()
expect(document.activeElement?.id).toBe('stay')
await wrapper.setProps({ open: false })
expect(dialog.attributes('open')).toBeUndefined()
expect(document.activeElement).toBe(before)
before.remove()
})
it('Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 닫을지는 쓰는 쪽이 정한다', async () => {
const wrapper = mountDialog(true)
await flushPromises()
const cancel = new Event('cancel', { cancelable: true })
wrapper.get('dialog').element.dispatchEvent(cancel)
expect(cancel.defaultPrevented).toBe(true)
expect(wrapper.emitted('cancel')).toHaveLength(1)
expect(wrapper.get('dialog').attributes('open')).toBeDefined()
})
it('열려 있어야 하는데 브라우저가 스스로 닫으면(close) cancel 로 알린다 — 쓰는 쪽이 닫았을 때는 알리지 않는다', async () => {
const wrapper = mountDialog(true)
await flushPromises()
const dialog = wrapper.get('dialog').element
dialog.dispatchEvent(new Event('close'))
expect(wrapper.emitted('cancel')).toHaveLength(1)
await wrapper.setProps({ open: false })
dialog.dispatchEvent(new Event('close'))
expect(wrapper.emitted('cancel')).toHaveLength(1)
})
it('한 대화상자가 닫히며 다른 대화상자가 열리면 닫기가 먼저다 — 여는 쪽을 먼저 고쳐도 둘이 겹쳐 열리지 않는다', async () => {
const first = ref<boolean>(true)
// 여는 쪽(B)을 앞에 둔다 — 화면을 고치는 순서가 B 먼저여도 A 가 먼저 닫혀야 한다 (세션 만료 대화상자가 앞에 있는 앱과 같다)
const Pair = defineComponent({
setup() {
return () => [
h(ModalDialog, { open: !first.value, title: 'B', message: 'b', titleId: 'b-t', messageId: 'b-m', id: 'b' }),
h(ModalDialog, { open: first.value, title: 'A', message: 'a', titleId: 'a-t', messageId: 'a-m', id: 'a' }),
]
},
})
const wrapper = mount(Pair, { attachTo: document.body })
mounted.push(wrapper)
await flushPromises()
const changes: string[] = []
const observer = new MutationObserver((records) => {
for (const record of records) {
const target = record.target as HTMLDialogElement
changes.push(`${target.id}:${target.hasAttribute('open') ? 'open' : 'closed'}`)
}
})
for (const dialog of wrapper.findAll('dialog')) {
observer.observe(dialog.element, { attributes: true, attributeFilter: ['open'] })
}
first.value = false
await nextTick()
await flushPromises()
observer.disconnect()
expect(changes).toEqual(['a:closed', 'b:open'])
})
})
@@ -0,0 +1,182 @@
<script setup lang="ts">
// 모달 대화상자 틀 — 브라우저 <dialog>(showModal)로 띄운다. 모양은 디자인 원본 States "세션 만료".
// 쓰는 곳: SessionExpiredDialog(세션 만료) · ComposeLeaveDialog(쓰기 화면 나가기 전 확인)
// - 모달이라 뒤의 화면을 조작할 수 없다. 처음 초점은 autofocus 를 붙인 단추 (없으면 브라우저가 첫 단추로)
// - Esc(cancel)는 브라우저가 닫지 않게 막고 cancel 로 알린다 — 무엇을 할지는 쓰는 쪽이 정한다
// - 닫기는 열기보다 먼저 한다 — 한 대화상자가 닫히고 다른 대화상자가 열리는 때(세션 만료) 둘이 겹치지 않게
// 단추는 actions 슬롯에 둔다: class="modal-dialog__action" (보조 단추는 modal-dialog__action--secondary 를 더한다)
import { onMounted, ref, watch } from 'vue'
interface Props {
open: boolean
title: string
message: string
/** 제목 · 글 문단의 id — aria-labelledby · aria-describedby 가 가리킨다 (화면에 하나뿐인 값) */
titleId: string
messageId: string
/** 사용자가 답해야 하는 확인이면 'alertdialog' — 주지 않으면 dialog 그대로 */
role?: 'alertdialog'
}
interface Emits {
/** Esc 로 닫으려 했다 — 브라우저가 막을 수 없게 스스로 닫았을 때도 같다 */
(e: 'cancel'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
const dialogRef = ref<HTMLDialogElement | null>(null)
/** 열기 전에 초점이 있던 곳 — 닫으면 돌려준다 (브라우저가 돌려주지 않는 환경에서도) */
let returnFocus: HTMLElement | null = null
function show() {
const dialog = dialogRef.value
if (!dialog || dialog.open) return
returnFocus = document.activeElement instanceof HTMLElement ? document.activeElement : null
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
if (typeof dialog.showModal === 'function') dialog.showModal()
else dialog.setAttribute('open', '')
// showModal 은 autofocus 단추로 초점을 옮긴다 — 없는 환경에서도 같게
dialog.querySelector<HTMLElement>('[autofocus]')?.focus()
}
function hide() {
const dialog = dialogRef.value
if (!dialog?.open) return
if (typeof dialog.close === 'function') dialog.close()
else dialog.removeAttribute('open')
// 초점이 대화상자 안에 남았거나 갈 곳을 잃었을 때만 돌려준다 (브라우저가 이미 돌려줬으면 그대로)
const active = document.activeElement
if (active === null || active === document.body || dialog.contains(active)) returnFocus?.focus()
returnFocus = null
}
function onCancel(event: Event) {
event.preventDefault()
emit('cancel')
}
/** 열려 있어야 하는데 닫혔다 — 브라우저가 스스로 닫았다 (Esc 를 거듭 누르는 등). 쓰는 쪽의 상태와 맞춘다 */
function onClose() {
if (props.open) emit('cancel')
}
// 닫기는 쓰는 쪽이 다시 그릴 때 바로, 열기는 화면을 다 고친 뒤에 (위 "닫기는 열기보다 먼저")
watch(
() => props.open,
(open) => {
if (!open) hide()
},
)
watch(
() => props.open,
(open) => {
if (open) show()
},
{ flush: 'post' },
)
onMounted(() => {
if (props.open) show()
})
</script>
<template>
<dialog
ref="dialogRef"
class="modal-dialog"
:role="role"
:aria-labelledby="titleId"
:aria-describedby="messageId"
@cancel="onCancel"
@close="onClose"
>
<h2
:id="titleId"
class="modal-dialog__title"
>
{{ title }}
</h2>
<p
:id="messageId"
class="modal-dialog__message"
>
{{ message }}
</p>
<div class="modal-dialog__actions">
<slot name="actions" />
</div>
</dialog>
</template>
<style scoped>
.modal-dialog {
width: min(22.5rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-18) var(--space-14);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-dialog);
}
.modal-dialog[open] {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.modal-dialog::backdrop {
background: var(--color-backdrop);
}
.modal-dialog__title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.modal-dialog__message {
margin: 0;
color: var(--color-text-secondary);
}
/* 단추 줄 — 오른쪽 정렬 */
.modal-dialog__actions {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
gap: var(--space-8);
margin-top: var(--space-4);
}
/* 주 단추 — 브랜드색 (슬롯으로 받은 단추라 :slotted 로 꾸민다) */
:slotted(.modal-dialog__action) {
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
:slotted(.modal-dialog__action:hover) {
background: var(--color-brand-strong);
}
/* 보조 단추 — 바탕 없이 (쓰기 화면의 "취소"와 같은 모양) */
:slotted(.modal-dialog__action--secondary) {
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
:slotted(.modal-dialog__action--secondary:hover) {
background: var(--color-surface-muted);
color: var(--color-text);
}
</style>
@@ -2,9 +2,10 @@
// 세션 만료(AUTH_002) 안내 — 디자인 원본 States "세션 만료"
// useApi 가 로그인 상태의 401(갱신 실패)을 받으면 authStore.expireSession() 을 부르고, 이 대화상자가 뜬다.
// 모달이라 뒤의 화면(이미 정리된 세션)을 더 조작할 수 없다. Esc 로 닫아도 로그인 화면으로 보낸다.
import { onMounted, ref, watch } from 'vue'
// 대화상자 틀(열기 · 닫기 · Esc · 모양)은 ModalDialog 가 맡는다.
import { useRoute, useRouter } from 'vue-router'
import ModalDialog from '@/components/feedback/ModalDialog.vue'
import { getErrorMessage } from '@/composables/useApi'
import { useAuthStore } from '@/stores/auth.store'
@@ -12,125 +13,34 @@ const authStore = useAuthStore()
const router = useRouter()
const route = useRoute()
const dialogRef = ref<HTMLDialogElement | null>(null)
/** 문구는 에러 코드 사전에서만 온다 */
const message = getErrorMessage('AUTH_002')
function open() {
const dialog = dialogRef.value
if (!dialog || dialog.open) return
// showModal 이 없는 환경(구형 브라우저·테스트)에서는 열린 상태만 표시한다
if (typeof dialog.showModal === 'function') dialog.showModal()
else dialog.setAttribute('open', '')
}
function close() {
const dialog = dialogRef.value
if (!dialog?.open) return
if (typeof dialog.close === 'function') dialog.close()
else dialog.removeAttribute('open')
}
async function goToLogin() {
// 로그인 후 보던 화면으로 돌아오게 한다 (로그인이 필요한 화면일 때만)
const redirect = route.meta.requiresAuth ? route.fullPath : undefined
authStore.acknowledgeSessionExpired()
await router.push({ name: 'login', query: redirect ? { redirect } : {} })
}
function onCancel(event: Event) {
event.preventDefault()
void goToLogin()
}
watch(
() => authStore.sessionExpired,
(expired) => (expired ? open() : close()),
{ flush: 'post' },
)
onMounted(() => {
if (authStore.sessionExpired) open()
})
</script>
<template>
<dialog
ref="dialogRef"
class="session-dialog"
aria-labelledby="session-expired-title"
aria-describedby="session-expired-message"
@cancel="onCancel"
<ModalDialog
:open="authStore.sessionExpired"
title="다시 로그인해 주세요"
:message="message"
title-id="session-expired-title"
message-id="session-expired-message"
@cancel="goToLogin"
>
<h2
id="session-expired-title"
class="session-dialog__title"
>
다시 로그인해 주세요
</h2>
<p
id="session-expired-message"
class="session-dialog__message"
>
{{ message }}
</p>
<button
type="button"
class="session-dialog__action"
@click="goToLogin"
>
로그인 화면으로
</button>
</dialog>
<template #actions>
<button
type="button"
class="modal-dialog__action"
@click="goToLogin"
>
로그인 화면으로
</button>
</template>
</ModalDialog>
</template>
<style scoped>
.session-dialog {
width: min(22.5rem, calc(100vw - var(--space-48)));
padding: var(--space-18) var(--space-18) var(--space-14);
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-xl);
background: var(--color-surface);
color: var(--color-text);
box-shadow: var(--shadow-dialog);
}
.session-dialog[open] {
display: flex;
flex-direction: column;
gap: var(--space-8);
}
.session-dialog::backdrop {
background: var(--color-backdrop);
}
.session-dialog__title {
margin: 0;
font-size: var(--font-size-title-sm);
font-weight: 700;
}
.session-dialog__message {
margin: 0;
color: var(--color-text-secondary);
}
.session-dialog__action {
align-self: flex-end;
display: inline-flex;
align-items: center;
min-height: var(--size-touch);
margin-top: var(--space-4);
padding: 0 var(--space-16);
border: 0;
border-radius: var(--radius-md);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.session-dialog__action:hover {
background: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,56 @@
<script setup lang="ts">
// 쓰기 화면 나가기 전 확인 — 디자인 README "나가기 전 확인" (세션 만료 대화상자와 같은 모양 · 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 { COMPOSE_LEAVE_DIALOG } from '@/shared/constants/mail'
interface Props {
open: boolean
}
interface Emits {
/** 계속 쓰기 (Esc 포함) — 이동을 취소한다 */
(e: 'stay'): void
/** 나가기 — 쓰던 내용을 버리고 이동한다 */
(e: 'leave'): void
}
defineProps<Props>()
const emit = defineEmits<Emits>()
</script>
<template>
<ModalDialog
id="compose-leave-dialog"
: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>
</template>
@@ -4,6 +4,7 @@ import { createPinia, setActivePinia, type Pinia } from 'pinia'
import { h } from 'vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { MAIL_BODY_NOTICES } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import { useMailStore } from '@/stores/mail.store'
import { useToastStore } from '@/stores/toast.store'
@@ -98,6 +99,68 @@ describe('MessageReader 도구 모음', () => {
})
})
describe('MessageReader 본문 안내 (계약 2절 "읽기 한도")', () => {
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] } })
}
function notices(wrapper: Awaited<ReturnType<typeof mountWith>>) {
return wrapper.findAll('.reader__notice').map((notice) => ({
kind: notice.attributes('data-notice'),
text: notice.text(),
}))
}
// 화면들이 같은 스토어(선택한 메일)를 보므로 한 번 띄울 때마다 바로 확인한다
it('다 받은 본문이면 안내가 없다 — 표시 값이 없는(이전 서버) 응답도 같다', async () => {
expect(notices(await mountWith(DETAIL))).toEqual([])
expect(notices(await mountWith({ ...DETAIL, isHtmlBodyOmitted: false, isBodyTruncated: false }))).toEqual([])
})
it('서식 본문을 비웠으면(isHtmlBodyOmitted) 글자 본문이 있을 때 · 없을 때 다른 문구', async () => {
const withText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: '글자 본문' })
expect(notices(withText)).toEqual([{ kind: 'htmlOmitted', text: MAIL_BODY_NOTICES.htmlOmitted }])
const withoutText = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, textBody: null })
expect(notices(withoutText)).toEqual([{ kind: 'unavailable', text: MAIL_BODY_NOTICES.unavailable }])
})
it('생략 → 잘림 순서로, 외부 이미지 차단 안내 앞에 같은 모양으로 둔다', async () => {
const wrapper = await mountWith({
...DETAIL,
htmlBody: '<p>앞부분</p>',
isHtmlBodyOmitted: false,
isBodyTruncated: true,
hasBlockedImages: true,
})
expect(notices(wrapper).map((notice) => notice.kind)).toEqual(['truncated', 'blockedImages'])
expect(notices(wrapper)[0]?.text).toBe(MAIL_BODY_NOTICES.truncated)
const both = await mountWith({ ...DETAIL, isHtmlBodyOmitted: true, isBodyTruncated: true, textBody: '긴 글' })
expect(notices(both).map((notice) => notice.kind)).toEqual(['htmlOmitted', 'truncated'])
// 안내 줄 모양은 하나 — 아이콘 + 글
for (const notice of both.findAll('.reader__notice')) {
expect(notice.element.tagName).toBe('P')
expect(notice.find('svg').exists()).toBe(true)
}
})
})
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 { mailBodyNotices } from '@/shared/utils/mail-body-notices'
import { useMailStore } from '@/stores/mail.store'
interface Props {
@@ -42,6 +43,8 @@ const fromEmail = computed(() => message.value?.from?.email ?? '')
const initial = computed(() => initialOf(message.value?.from?.name, fromEmail.value))
const subject = computed(() => message.value?.subject || '(제목 없음)')
const receivedAt = computed(() => (message.value ? formatMailDateTime(message.value.receivedAt) : ''))
/** 서버가 본문을 다 주지 못했을 때의 안내 — 서식을 비웠으면 먼저, 잘렸으면 다음 (계약 2절 "읽기 한도") */
const bodyNotices = computed(() => (message.value ? mailBodyNotices(message.value) : []))
async function onDelete() {
const current = message.value
@@ -203,9 +206,25 @@ function leaveToList() {
</div>
</div>
<!-- 본문 안내 — 본문이 다 보이는지가 먼저다 (생략 → 잘림), 그다음 외부 이미지 차단 -->
<p
v-for="notice in bodyNotices"
:key="notice.kind"
class="reader__notice"
:data-notice="notice.kind"
>
<AppIcon
name="alert-circle"
:size="15"
class="reader__notice-icon"
/>
<span>{{ notice.text }}</span>
</p>
<p
v-if="message.hasBlockedImages"
class="reader__notice"
data-notice="blockedImages"
>
<AppIcon
name="shield"
@@ -41,6 +41,8 @@ const draft = ref<string>('')
const inputRef = ref<HTMLInputElement | null>(null)
const hasInvalid = computed(() => props.modelValue.some((token) => !token.valid))
/** 아직 토큰이 되지 않은 입력이 있다 — 쓰기 화면이 "쓰던 내용"으로 본다 (나가기 전 확인 · 칸을 벗어나지 않고 뒤로 가기 등) */
const hasDraft = computed(() => draft.value.trim() !== '')
/** 입력한 글을 토큰으로 확정한다 (여러 주소를 붙여 넣었으면 나눠서) */
function commit(text: string = draft.value) {
@@ -90,7 +92,12 @@ function focus() {
inputRef.value?.focus()
}
defineExpose({ commit, focus })
/** 아직 토큰이 되지 않은 입력을 버린다 — 쓰기 화면이 양식을 새로 채울 때 */
function clearDraft() {
draft.value = ''
}
defineExpose({ commit, focus, clearDraft, hasDraft })
</script>
<template>
@@ -45,7 +45,7 @@ interface Emits {
(e: 'ready'): void
/** 본문에 붙여 넣거나 끌어다 놓은 파일 — 첨부로 올린다 */
(e: 'files', files: File[]): void
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) — 연 때와 다른지는 isChanged 로 묻는다 */
(e: 'change'): void
}
@@ -65,6 +65,12 @@ const revision = ref<number>(0)
let pastedImageCount = 0
/** 편집 영역을 화면에 붙였다 — 그 전에는 편집기 화면(view)이 없다 */
let mounted = false
/**
* 편집기를 다 연 때의 문서(답장 · 전달 초안은 맞춘 뒤) — 쓰기 화면이 "본문을 바꿨는가"를 이것과 비교한다 (나가기 전 확인).
* settled: 같은 문서에 편집기가 스스로 끝 빈 줄을 붙인 모양 — 끝이 인용처럼 문단이 아니면 첫 거래(초점 옮기기 등) 때
* 붙인다(StarterKit 의 TrailingNode). 사용자가 쓴 것이 아니므로 "바꿈"이 아니다.
*/
let opened: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
function initialContent(extensions: AnyExtension[]): Content {
if (!props.initialHtml) return ''
@@ -176,6 +182,13 @@ 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)
}
/**
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
@@ -194,8 +207,11 @@ onMounted(() => {
editor.mount(bodyRef.value)
mounted = true
}
// 붙이면서 플러그인(실행 취소 기록 등)이 켜진다 — 툴바가 다시 읽게 한다
// 붙이면서 플러그인(실행 취소 기록 · 끝 빈 줄 등)이 켜진다 — 툴바가 다시 읽게 하고, 연 때의 문서를 찍어 둔다.
// settled 는 빈 거래 하나를 적용해 본 모양이다 (화면에 보내지 않는다 — 플러그인이 붙일 것만 미리 본다)
revision.value += 1
const { state } = editor
opened = { doc: state.doc, settled: state.apply(state.tr).doc }
emit('ready')
})
@@ -211,7 +227,7 @@ watch(
},
)
defineExpose({ getContent, getContentForSend, focus })
defineExpose({ getContent, getContentForSend, focus, isChanged })
</script>
<template>
@@ -116,7 +116,7 @@ describe('fitDraftDoc — 답장·전달 초안 한도 (계약 2절)', () => {
expect(fitted.child(0).textContent).toBe('내가 쓴 첫 줄')
})
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 보낼 때 서버가 VAL_001 로 막는다)', () => {
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 쓰기 화면이 보내지 않고, 보내도 서버가 MAIL_007 로 막는다)', () => {
const doc = createDocument(lines(200), schema)
expect(fitDraftDoc(doc, schema, { text: 100, html: 1_000 })).toBe(doc)
@@ -16,7 +16,7 @@ export interface DraftLimits {
html: number
}
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 MAIL_007) */
export const DRAFT_LIMITS: DraftLimits = { text: MAIL_BODY_MAX_LENGTH, html: MAIL_BODY_HTML_MAX_LENGTH }
/**
@@ -19,6 +19,11 @@ export interface MailEditorHandle {
getContentForSend: () => MailEditorContent | null
/** 본문으로 초점을 옮긴다 — 'start' 는 맨 위 첫 줄(답장), 'end' 는 맨 끝, 주지 않으면 커서 자리 그대로 */
focus: (position?: 'start' | 'end') => void
/**
* 편집기를 연 뒤(답장 · 전달 초안을 맞춘 뒤) 사용자가 본문을 바꿨는가 — 쓰기 화면의 나가기 전 확인이 쓴다.
* 지웠다 다시 써 처음과 같아졌거나, 편집기가 스스로 붙인 끝 빈 줄뿐이면 false
*/
isChanged: () => boolean
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
@@ -79,6 +79,22 @@ describe('글자 서식 값 — 서버가 남기는 형식만 읽는다 (보이
expect(outgoingHtml(doc)).not.toMatch(/font-size: 0|transparent/)
})
it('보이지 않을 만큼 작은 글자(8px 미만) · 거의 투명한 글자 색(알파 0.3 미만)도 서식째 버린다 — 배경 색의 알파는 그대로', () => {
const tiny = parse('<p><span style="font-size: 1px">작은 글</span></p>')
const pale = parse('<p><span style="color: rgba(0, 0, 0, 0.1)">흐린 글</span></p>')
const word = parse('<p><span style="font-size: 5.25pt">워드 7px</span></p>')
const highlight = parse('<p><span style="background-color: rgba(0, 0, 0, 0)">배경</span></p>')
expect(firstTextStyle(tiny)?.fontSize ?? null).toBeNull()
expect(firstTextStyle(pale)?.color ?? null).toBeNull()
expect(firstTextStyle(word)?.fontSize ?? null).toBeNull()
expect(outgoingHtml(tiny)).not.toContain('font-size: 1px')
expect(outgoingHtml(pale)).not.toContain('rgba')
expect(docToText(tiny)).toBe('작은 글')
expect(firstTextStyle(highlight)?.backgroundColor).toBe('rgba(0, 0, 0, 0)')
expect(firstTextStyle(parse('<p><span style="font-size: 6pt">a</span></p>'))?.fontSize).toBe('8px')
})
it('pt · 긴 소수의 글자 크기는 px 로 맞춰 읽는다 (워드 10pt · 아웃룩 13.3333px), !important 는 뗀다', () => {
expect(firstTextStyle(parse('<p><span style="font-size: 10pt">a</span></p>'))?.fontSize).toBe('13.33px')
expect(firstTextStyle(parse('<p><span style="font-size: 13.3333px">a</span></p>'))?.fontSize).toBe('13.33px')