generated from ttipo/project-base
feat(frontend): 메일 쓰기 서식 편집기와 입력칸 포커스 통일
메일 쓰기 화면은 입력칸마다 포커스 모양이 달랐고, 본문은 서식 없는 글만 쓸 수 있었다. - 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)의 포커스를 아래 선 하나(.compose-field)로 통일했다 - 본문을 TipTap 서식 편집기로 바꿨다 — 글꼴·크기·굵게·색·목록·링크·정렬·들여쓰기·인용·표·가로줄, 단축키, 좁은 폭에서 묶음째 접는 툴바. 편집기는 쓰기 화면에서 따로 불러온다(지연 로딩) - 보낼 때는 기본 글꼴(맑은 고딕) div 와 인라인 style 의 HTML 에 텍스트 대체 본문을 함께 보낸다 (여러 칸 공백은 nbsp, 탭은 8칸 자리까지) - 붙여 넣은 글·원문 인용은 서버 정화와 같은 값 형식만 읽는다 — 편집기에서 숨은 글이 받는 사람에게 보이지 않게 - 답장·전달 인용은 "원본 메일" 머리와 함께 서식째 옮긴다. 초안은 내 글 몫 5%를 남기고, 보내기 직전에 다시 재서 인용을 줄인다 - 본문에 붙여 넣거나 끌어다 놓은 이미지·파일은 첨부로 올린다 — 화면 어디에 놓아도 (브라우저가 파일을 열어 쓰던 메일을 잃지 않게) 의존성: @tiptap/* 3.31.4 (MIT 무료 패키지만, 모두 같은 판으로 고정) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,193 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
|
||||
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
/**
|
||||
* 편집기 청크 내려받기를 마음대로 — 실패시키거나(첫 번째만) 사용자가 놓아 줄 때까지 붙잡는다.
|
||||
* ComposePage 는 mail-editor-handle.ts 의 loadMailEditor 로 청크를 받는다.
|
||||
*/
|
||||
const loader = vi.hoisted(() => ({
|
||||
mode: 'normal' as 'normal' | 'fail-once' | 'hold',
|
||||
calls: 0,
|
||||
release: null as null | (() => void),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/mail/editor/mail-editor-handle', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/components/mail/editor/mail-editor-handle')>()
|
||||
return {
|
||||
...actual,
|
||||
loadMailEditor: () => {
|
||||
loader.calls += 1
|
||||
if (loader.mode === 'fail-once' && loader.calls === 1) {
|
||||
return Promise.reject(new Error('Failed to fetch dynamically imported module'))
|
||||
}
|
||||
if (loader.mode === 'hold') {
|
||||
return new Promise((resolve) => {
|
||||
loader.release = () => resolve(actual.loadMailEditor())
|
||||
})
|
||||
}
|
||||
return actual.loadMailEditor()
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 10_000
|
||||
|
||||
const ORIGINAL: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: '메일 서버 이전 일정 공유',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: '<m1@arasystem.it.kr>',
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '김아라님, 안녕하세요.',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
const api: MockHandler = ({ method, url }) => {
|
||||
if (method === 'GET' && url === '/messages/m1') return ok(ORIGINAL)
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
|
||||
describe('ComposePage — 서식 편집기 청크', () => {
|
||||
let pinia: Pinia
|
||||
let wrapper: VueWrapper | null = null
|
||||
const loadErrors: unknown[] = []
|
||||
|
||||
beforeAll(installProseMirrorDom)
|
||||
beforeAll(async () => {
|
||||
await import('@/components/mail/editor/MailEditor.vue')
|
||||
}, EDITOR_LOAD_TIMEOUT_MS)
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
loader.mode = 'normal'
|
||||
loader.calls = 0
|
||||
loader.release = null
|
||||
loadErrors.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountCompose(path: string): Promise<VueWrapper> {
|
||||
installMockApi(api)
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
wrapper = mount(ComposePage, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
// 내려받기 오류는 Vue 가 앱 오류로도 알린다 — 테스트에서는 모아 둔다
|
||||
config: { errorHandler: (error) => loadErrors.push(error) },
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤 */
|
||||
async function releaseEditor() {
|
||||
await vi.waitFor(() => {
|
||||
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
|
||||
})
|
||||
loader.release?.()
|
||||
}
|
||||
|
||||
async function editorLoaded(view: VueWrapper) {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!view.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도" — 보내기는 막힌다. 다시 시도하면 편집기가 뜬다', async () => {
|
||||
loader.mode = 'fail-once'
|
||||
const view = await mountCompose('/mail/compose')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
|
||||
})
|
||||
const alert = view.get('.compose__editor [role="alert"]')
|
||||
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(view.get('.compose__send').attributes('disabled')).toBeDefined()
|
||||
// 편집기에 넘기던 속성(초안 HTML 등)이 오류 화면에 붙지 않는다
|
||||
expect(alert.attributes('initial-html')).toBeUndefined()
|
||||
expect(loadErrors).toHaveLength(1)
|
||||
|
||||
const retry = alert.findAll('button').find((button) => button.text() === '다시 시도')
|
||||
// 누르면 단추에 초점이 간다 (브라우저처럼)
|
||||
;(retry?.element as HTMLElement | undefined)?.focus()
|
||||
await retry?.trigger('click')
|
||||
await editorLoaded(view)
|
||||
|
||||
expect(loader.calls).toBe(2)
|
||||
expect(view.find('.compose__editor [role="alert"]').exists()).toBe(false)
|
||||
expect(view.get('.compose__send').attributes('disabled')).toBeUndefined()
|
||||
// 누른 "다시 시도" 단추가 사라졌다 — 초점은 본문으로
|
||||
await vi.waitFor(() => {
|
||||
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
|
||||
})
|
||||
})
|
||||
|
||||
it('답장 — 청크가 늦게 와도 이미 다른 칸(제목)에 쓰고 있으면 본문으로 초점을 빼앗지 않는다', async () => {
|
||||
loader.mode = 'hold'
|
||||
const view = await mountCompose('/mail/compose?replyTo=m1')
|
||||
const subject = view.get('#compose-subject').element as HTMLInputElement
|
||||
subject.focus()
|
||||
|
||||
await releaseEditor()
|
||||
await editorLoaded(view)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(subject)
|
||||
})
|
||||
|
||||
it('답장 — 아무 칸에도 초점이 없으면 편집기가 준비될 때 본문 맨 위로', async () => {
|
||||
loader.mode = 'hold'
|
||||
const view = await mountCompose('/mail/compose?replyTo=m1')
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
|
||||
await releaseEditor()
|
||||
await editorLoaded(view)
|
||||
await vi.waitFor(() => {
|
||||
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
|
||||
})
|
||||
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -17,10 +23,13 @@ import {
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다 */
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 10_000
|
||||
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
|
||||
|
||||
const ORIGINAL: MessageDetail = {
|
||||
@@ -51,6 +60,12 @@ const ORIGINAL: MessageDetail = {
|
||||
describe('ComposePage', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeAll(installProseMirrorDom)
|
||||
// 편집기 모듈을 미리 불러 둔다 — 첫 변환 시간(한꺼번에 돌리면 수 초)이 첫 테스트의 시간 한도(기본 5초)에 잡히지 않게
|
||||
beforeAll(async () => {
|
||||
await import('@/components/mail/editor/MailEditor.vue')
|
||||
}, EDITOR_LOAD_TIMEOUT_MS)
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
@@ -60,13 +75,19 @@ describe('ComposePage', () => {
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
/** 이 테스트에서 띄운 화면 — 쓰기 화면은 window 에 파일 끌기 처리기를 달므로 다음 테스트로 새지 않게 내린다 */
|
||||
const mountedWrappers: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mountedWrappers.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** attach: 초점 이동을 확인할 때 문서에 붙인다 (테스트 끝에 wrapper.unmount()) */
|
||||
/** attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다 */
|
||||
async function mountCompose(path: string, handler: MockHandler, attach = false) {
|
||||
const requests = installMockApi(handler)
|
||||
const router = createTestRouter()
|
||||
@@ -76,10 +97,28 @@ describe('ComposePage', () => {
|
||||
attachTo: attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
await editorLoaded(wrapper)
|
||||
return { wrapper, router, requests }
|
||||
}
|
||||
|
||||
/** 서식 편집기는 지연 로딩한다 — 불러와 준비될 때까지 기다린다 (처음 불러올 때는 변환에 시간이 걸린다) */
|
||||
async function editorLoaded(wrapper: VueWrapper) {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!wrapper.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** 본문 편집 영역에 보이는 글 */
|
||||
function bodyText(wrapper: VueWrapper): string {
|
||||
return wrapper.get('#compose-body').text()
|
||||
}
|
||||
|
||||
function chips(wrapper: VueWrapper, inputId: string): string[] {
|
||||
const field = wrapper.get(`#${inputId}`).element.closest('.recipient-field')
|
||||
return Array.from(field?.querySelectorAll('.recipient-chip__label') ?? []).map(
|
||||
@@ -105,10 +144,25 @@ describe('ComposePage', () => {
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
|
||||
'Re: 메일 서버 이전 일정 공유',
|
||||
)
|
||||
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
|
||||
expect(body).toContain('──────────')
|
||||
expect(body).toContain('이도윤 <doyun.lee@arasystem.it.kr> 작성:')
|
||||
expect(body).toContain('> 김아라님, 안녕하세요.')
|
||||
// 원문은 "원본 메일" 머리(보낸 사람 · 날짜 · 제목)와 함께 인용 막대 안에 (디자인 README "원문 인용")
|
||||
const quote = wrapper.get('#compose-body blockquote[data-mail-quote]')
|
||||
expect(quote.get('p[data-quote-header]').text()).toBe('원본 메일')
|
||||
expect(quote.get('table[data-quote-header]').text()).toContain('보낸 사람이도윤 <doyun.lee@arasystem.it.kr>')
|
||||
expect(quote.text()).toContain('김아라님, 안녕하세요.')
|
||||
expect(quote.text()).not.toContain('받는 사람')
|
||||
})
|
||||
|
||||
it('답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
|
||||
expect(editorOf(wrapper.element).state.selection.from).toBe(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('전체 답장(&all=1)은 원래 참조까지 채운다', async () => {
|
||||
@@ -130,7 +184,11 @@ describe('ComposePage', () => {
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: 'Re: 메일 서버 이전 일정 공유',
|
||||
// 서식 본문과 같은 내용의 텍스트 — 인용은 "> " (계약 2절 "서식 본문")
|
||||
body: expect.stringContaining('> 김아라님, 안녕하세요.'),
|
||||
bodyHtml: expect.stringMatching(
|
||||
/^<div style="font-family: 'Malgun Gothic'.*<blockquote style="margin: 24px 0 0; .*원본 메일.*<\/div>$/,
|
||||
),
|
||||
attachments: [],
|
||||
inReplyTo: 'm1',
|
||||
})
|
||||
@@ -202,10 +260,13 @@ describe('ComposePage', () => {
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
|
||||
'Fwd: 메일 서버 이전 일정 공유',
|
||||
)
|
||||
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
|
||||
expect(body).toContain('전달된 메일')
|
||||
expect(body).toContain('보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>')
|
||||
expect(body).toContain('김아라님, 안녕하세요.')
|
||||
// 전달 머리에는 받는 사람 · 참조도 있다
|
||||
const header = wrapper.get('#compose-body table[data-quote-header]').text()
|
||||
expect(bodyText(wrapper)).toContain('원본 메일')
|
||||
expect(header).toContain('보낸 사람이도윤 <doyun.lee@arasystem.it.kr>')
|
||||
expect(header).toContain('받는 사람김아라 <ara.kim@arasystem.it.kr>')
|
||||
expect(header).toContain('참조인프라팀 <infra@arasystem.it.kr>')
|
||||
expect(bodyText(wrapper)).toContain('김아라님, 안녕하세요.')
|
||||
expect(attachmentNames(wrapper)).toEqual(['체크리스트.xlsx', '견적.pdf'])
|
||||
})
|
||||
|
||||
@@ -399,6 +460,240 @@ describe('ComposePage', () => {
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('서식 본문 (계약 2절 "서식 본문")', () => {
|
||||
const uploadApi: MockHandler = (request) => {
|
||||
if (request.method === 'POST' && request.url === '/mail-attachments') {
|
||||
const file = (request.data as FormData).get('file') as File
|
||||
return ok({ id: `up-${file.name}`, name: file.name, type: file.type, size: file.size }, 201)
|
||||
}
|
||||
return replyApi(request)
|
||||
}
|
||||
|
||||
async function addRecipient(wrapper: VueWrapper) {
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('doyun.lee@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
}
|
||||
|
||||
function uploadedNames(requests: MockRequest[]): string[] {
|
||||
return requests
|
||||
.filter((request) => request.method === 'POST' && request.url === '/mail-attachments')
|
||||
.map((request) => ((request.data as FormData).get('file') as File).name)
|
||||
}
|
||||
|
||||
it('쓴 서식은 서식 본문(bodyHtml)으로, 같은 내용은 텍스트(body)로 함께 보낸다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
|
||||
await addRecipient(wrapper)
|
||||
editorOf(wrapper.element).commands.setContent('<p>확인했습니다.</p><ol><li><p>DNS 낮추기</p></li></ol>')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
|
||||
expect(payload?.body).toBe('확인했습니다.\n1. DNS 낮추기')
|
||||
expect(payload?.bodyHtml).toBe(
|
||||
`<div style="font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: 1.75">` +
|
||||
'<p style="margin: 0">확인했습니다.</p>' +
|
||||
'<ol style="margin: 0; padding-left: 24px"><li><p style="margin: 0">DNS 낮추기</p></li></ol></div>',
|
||||
)
|
||||
})
|
||||
|
||||
it('본문이 비었으면 서식 본문 없이 빈 텍스트로 보낸다 (텍스트 메일)', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
|
||||
await addRecipient(wrapper)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
|
||||
expect(payload?.body).toBe('')
|
||||
expect(payload).not.toHaveProperty('bodyHtml')
|
||||
})
|
||||
|
||||
it('본문에 붙여 넣은 그림은 "붙여넣은 이미지 1.png" 첨부로 올리고 알린다 — 본문에는 넣지 않는다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi)
|
||||
|
||||
wrapper
|
||||
.get('.ProseMirror')
|
||||
.element.dispatchEvent(pasteEvent({ files: [new File(['png'], 'image.png', { type: 'image/png' })] }))
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadedNames(requests)).toEqual(['붙여넣은 이미지 1.png'])
|
||||
expect(wrapper.findAll('.upload-item__name').map((item) => item.text())).toEqual(['붙여넣은 이미지 1.png'])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
'이미지는 본문 대신 첨부 파일로 올렸습니다.',
|
||||
])
|
||||
expect(wrapper.find('#compose-body img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('파일을 끄는 동안 본문 위에 덮개를 보이고, 쓰기 화면 어디에 놓아도 첨부로 올린다 — 이미지가 아니면 알리지 않는다', async () => {
|
||||
// 끌기는 화면(window) 전체에서 받는다 — 문서에 붙여야 window 까지 올라간다
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
|
||||
const report = new File(['pdf'], '보고서.pdf', { type: 'application/pdf' })
|
||||
|
||||
wrapper.get('#compose-subject').element.dispatchEvent(fileDragEvent('dragenter'))
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.mail-editor__drop').text()).toBe('놓으면 첨부 파일로 올립니다')
|
||||
|
||||
// 첨부 영역에 놓는다 (본문 밖)
|
||||
wrapper.get('.compose__attachments').element.dispatchEvent(fileDragEvent('drop', [report]))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.mail-editor__drop').exists()).toBe(false)
|
||||
expect(uploadedNames(requests)).toEqual(['보고서.pdf'])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
// 칸 사이를 지나도 깜빡이지 않는다 — 들어온 만큼 나가야(쓰기 화면 밖으로) 끈다
|
||||
const subjectInput = wrapper.get('#compose-subject').element
|
||||
const body = wrapper.get('.ProseMirror').element
|
||||
subjectInput.dispatchEvent(fileDragEvent('dragenter'))
|
||||
body.dispatchEvent(fileDragEvent('dragenter'))
|
||||
subjectInput.dispatchEvent(fileDragEvent('dragleave'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.mail-editor__drop').exists()).toBe(true)
|
||||
|
||||
body.dispatchEvent(fileDragEvent('dragleave', [], { relatedTarget: null }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.mail-editor__drop').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('쓰기 칸(폼) 밖 빈 곳에 놓아도 첨부로 올리고 브라우저가 파일을 열지 않게 한다 — 화면을 떠나면 받지 않는다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
|
||||
const report = new File(['pdf'], '회의록.pdf', { type: 'application/pdf' })
|
||||
|
||||
const over = fileDragEvent('dragover')
|
||||
document.body.dispatchEvent(over)
|
||||
const drop = fileDragEvent('drop', [report])
|
||||
document.body.dispatchEvent(drop)
|
||||
await flushPromises()
|
||||
|
||||
expect(over.defaultPrevented).toBe(true)
|
||||
expect(drop.defaultPrevented).toBe(true)
|
||||
expect(uploadedNames(requests)).toEqual(['회의록.pdf'])
|
||||
|
||||
wrapper.unmount()
|
||||
const later = fileDragEvent('drop', [report])
|
||||
document.body.dispatchEvent(later)
|
||||
await flushPromises()
|
||||
expect(later.defaultPrevented).toBe(false)
|
||||
expect(uploadedNames(requests)).toEqual(['회의록.pdf'])
|
||||
})
|
||||
|
||||
it('붙여 넣은 그림이 사전 검사에 걸리면(허용 밖 형식) 올리지 않고 "첨부로 올렸습니다" 알림도 띄우지 않는다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi)
|
||||
|
||||
wrapper
|
||||
.get('.ProseMirror')
|
||||
.element.dispatchEvent(pasteEvent({ files: [new File(['bmp'], 'image.bmp', { type: 'image/bmp' })] }))
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadedNames(requests)).toEqual([])
|
||||
expect(wrapper.get('.upload-item--failed .upload-item__error').text()).toBe(ERROR_CODE_MESSAGES.FILE_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('본문에 놓은 파일은 편집기가 넘긴 한 번만 올린다 (화면 전체의 놓기 처리는 건너뛴다)', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
|
||||
const photo = new File(['png'], '현장.png', { type: 'image/png' })
|
||||
|
||||
wrapper.get('.ProseMirror').element.dispatchEvent(fileDragEvent('drop', [photo]))
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadedNames(requests)).toEqual(['현장.png'])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
'이미지는 본문 대신 첨부 파일로 올렸습니다.',
|
||||
])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('입력칸마다 같은 포커스 표시 — 받는 사람 · 참조 · 숨은참조 · 제목 행과 본문 편집 영역에 compose-field', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(wrapper.findAll('.compose-field')).toHaveLength(5)
|
||||
expect(wrapper.get('#compose-subject').element.closest('.compose-field')).not.toBeNull()
|
||||
expect(wrapper.get('#compose-body').element.closest('.compose-field')).not.toBeNull()
|
||||
// 툴바(링크 팝오버의 입력칸)는 본문 선 밖이다 — 테두리 있는 칸은 고리를 쓴다
|
||||
expect(wrapper.get('[role="toolbar"]').element.closest('.compose-field')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('보내기 직전의 길이 — 제목 255 · 본문 텍스트 100,000 · 서식 500,000 (계약 2절)', () => {
|
||||
async function addRecipient(wrapper: VueWrapper) {
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('doyun.lee@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
}
|
||||
|
||||
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(VAL_001) — 본문으로 초점, 고치면 내린다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
|
||||
await addRecipient(wrapper)
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.setContent(`<p>${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)}</p>`)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
editor.commands.insertContent('나')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#compose-body-error').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('답장 원문이 길어도 맞춘 초안에 내 글을 쓰면 보낸다 — 남겨 둔 자리보다 길면 인용을 더 줄여(생략 표시) 보낸다', async () => {
|
||||
const longOriginal = { ...ORIGINAL, textBody: `${'긴 원문 줄입니다. '.repeat(10)}\n`.repeat(1_000) }
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) =>
|
||||
request.method === 'GET' && request.url === '/messages/m1' ? ok(longOriginal) : replyApi(request),
|
||||
)
|
||||
const mine = '내가 쓴 긴 답장입니다. '.repeat(600)
|
||||
editorOf(wrapper.element).commands.insertContentAt(1, mine)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const payload = sendRequest(requests)?.data as { body: string; bodyHtml: string } | undefined
|
||||
expect(payload?.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(payload?.bodyHtml.length).toBeLessThanOrEqual(MAIL_BODY_HTML_MAX_LENGTH)
|
||||
expect(payload?.body.startsWith(mine.trimEnd())).toBe(true)
|
||||
expect(payload?.body.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('제목이 255자를 넘으면(붙여 넣기 등) 보내지 않고 제목 아래에 사전 문구 — 제목으로 초점', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
|
||||
await addRecipient(wrapper)
|
||||
await wrapper.get('#compose-subject').setValue('제'.repeat(MAIL_SUBJECT_MAX_LENGTH + 1))
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
expect(wrapper.get('#compose-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#compose-subject').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('compose-subject')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('제목에서 Enter 를 누르면 본문의 커서 자리 그대로 본문으로 간다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi, true)
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.setContent('<p>첫 줄</p><p>둘째 줄</p>')
|
||||
editor.commands.setTextSelection(8)
|
||||
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
|
||||
|
||||
await wrapper.get('#compose-subject').trigger('keydown', { key: 'Enter' })
|
||||
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
expect(editor.state.selection.from).toBe(8)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('중복 발송 방지 키 (Idempotency-Key, 계약 2절)', () => {
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 쓰기 (디자인 원본 Compose) — /mail/compose
|
||||
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 인용 본문을 채운다.
|
||||
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 본문 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
|
||||
// 메일 쓰기 (디자인 원본 Compose · ComposeEditor) — /mail/compose
|
||||
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 원문 인용을 채운다.
|
||||
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 인용 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
|
||||
// 본문은 서식 편집기(TipTap) — 이 화면에서만 불러온다(지연 로딩). 보낼 때 서식 본문(bodyHtml)과 같은 내용의 텍스트(body)를 함께 보낸다.
|
||||
// 편집기 청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도"를 보인다 (ComposeEditorLoadError).
|
||||
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문에는 이미지를 넣지 않는다 (사용자 확정).
|
||||
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
|
||||
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구(VAL_001).
|
||||
import { computed, defineAsyncComponent, nextTick, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
|
||||
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
|
||||
import RecipientField from '@/components/mail/RecipientField.vue'
|
||||
import {
|
||||
MAIL_EDITOR_RETRY,
|
||||
loadMailEditor,
|
||||
type MailEditorHandle,
|
||||
} from '@/components/mail/editor/mail-editor-handle'
|
||||
import { getErrorMessage, toApiError } from '@/composables/useApi'
|
||||
import { useAttachmentUploads } from '@/composables/useAttachmentUploads'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
|
||||
import {
|
||||
COMPOSE_TITLES,
|
||||
MAIL_ATTACHMENT_ACCEPT,
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_MAX_ATTACHMENTS,
|
||||
MAIL_MAX_RECIPIENTS,
|
||||
MAIL_SENT_ACTION_LABEL,
|
||||
@@ -24,7 +35,9 @@ import {
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
type ComposeMode,
|
||||
} from '@/shared/constants/mail'
|
||||
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
|
||||
import type { SendMessagePayload } from '@/shared/types/mail'
|
||||
import { isImageFile } from '@/shared/utils/editor-files'
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
shouldRenewSendKey,
|
||||
@@ -37,8 +50,20 @@ import { buildReplyDraft } from '@/shared/utils/mail-reply'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 서식 편집기 — TipTap 을 쓰기 화면에서만 내려받는다.
|
||||
* 못 받으면 본문 자리에 사전 문구와 "다시 시도" — 다시 시도하면 편집기를 새로 만들어 청크를 다시 받는다 (MAIL_EDITOR_RETRY).
|
||||
*/
|
||||
const MailEditor = defineAsyncComponent({
|
||||
loader: loadMailEditor,
|
||||
errorComponent: ComposeEditorLoadError,
|
||||
})
|
||||
|
||||
/** 받는 사람 오류 안내 문단 — 세 입력칸이 함께 가리킨다 */
|
||||
const RECIPIENT_ERROR_ID = 'compose-recipient-error'
|
||||
/** 길이 오류 안내 문단 — 제목 · 본문이 각각 가리킨다 */
|
||||
const SUBJECT_ERROR_ID = 'compose-subject-error'
|
||||
const BODY_ERROR_ID = 'compose-body-error'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -68,7 +93,16 @@ const cc = ref<RecipientToken[]>([])
|
||||
const bcc = ref<RecipientToken[]>([])
|
||||
const showCopies = ref<boolean>(false)
|
||||
const subject = ref<string>('')
|
||||
const body = ref<string>('')
|
||||
/** 편집기의 처음 내용 — 답장·전달의 원문 인용 (편집기를 새로 만들 때만 읽는다) */
|
||||
const draftHtml = ref<string>('')
|
||||
/** 처음 내용을 본문 한도에 맞출지 — 답장·전달 초안 */
|
||||
const fitDraft = ref<boolean>(false)
|
||||
/** 양식을 새로 채울 때마다 바꿔 편집기를 새로 만든다 (실행 취소 기록도 새로 시작한다) */
|
||||
const editorKey = ref<number>(0)
|
||||
/** 편집기를 다 불러왔다 — 그 전에는 본문을 읽을 수 없어 보내지 않는다 */
|
||||
const editorReady = ref<boolean>(false)
|
||||
/** 편집기가 준비되면 본문 맨 위로 커서를 옮긴다 (답장) */
|
||||
let focusBodyWhenReady = false
|
||||
/** 답장할 원문을 불러오는 중 — 그동안 입력을 막아 미리 채운 값이 사용자 입력을 덮지 않게 한다 */
|
||||
const preparing = ref<boolean>(false)
|
||||
const sending = ref<boolean>(false)
|
||||
@@ -76,6 +110,8 @@ const sending = ref<boolean>(false)
|
||||
const attempted = ref<boolean>(false)
|
||||
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
|
||||
const rejectedByServer = ref<boolean>(false)
|
||||
/** 보내기 직전에 잰 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도) */
|
||||
const lengthError = ref<'subject' | 'body' | null>(null)
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
|
||||
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
|
||||
@@ -87,7 +123,8 @@ let sendKey = createIdempotencyKey()
|
||||
const toField = ref<InstanceType<typeof RecipientField> | null>(null)
|
||||
const ccField = ref<InstanceType<typeof RecipientField> | null>(null)
|
||||
const bccField = ref<InstanceType<typeof RecipientField> | null>(null)
|
||||
const bodyRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const editorRef = ref<MailEditorHandle | null>(null)
|
||||
const subjectInputRef = ref<HTMLInputElement | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const attachButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentList> | null>(null)
|
||||
@@ -127,10 +164,29 @@ const showRecipientError = computed(
|
||||
)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const recipientErrorMessage = getErrorMessage('MAIL_002')
|
||||
/** 길이가 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
|
||||
const lengthErrorMessage = getErrorMessage('VAL_001')
|
||||
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
|
||||
const canSend = computed(
|
||||
() => !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
|
||||
() =>
|
||||
editorReady.value && !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
|
||||
)
|
||||
/** 편집기를 새로 만든다 — 다음에 그릴 때 draftHtml 로 시작한다 */
|
||||
function renewEditor() {
|
||||
editorKey.value += 1
|
||||
editorReady.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기 청크를 못 받았을 때의 "다시 시도" — 편집기를 새로 만들어 다시 받는다.
|
||||
* 누른 단추가 편집기로 바뀌며 사라지므로, 준비되면 초점을 본문으로 옮긴다 (다른 칸에 초점이 없을 때만).
|
||||
*/
|
||||
function retryEditor() {
|
||||
focusBodyWhenReady = true
|
||||
renewEditor()
|
||||
}
|
||||
|
||||
provide(MAIL_EDITOR_RETRY, retryEditor)
|
||||
|
||||
function resetForm() {
|
||||
to.value = []
|
||||
@@ -138,9 +194,13 @@ function resetForm() {
|
||||
bcc.value = []
|
||||
showCopies.value = false
|
||||
subject.value = ''
|
||||
body.value = ''
|
||||
draftHtml.value = ''
|
||||
fitDraft.value = false
|
||||
focusBodyWhenReady = false
|
||||
renewEditor()
|
||||
attempted.value = false
|
||||
rejectedByServer.value = false
|
||||
lengthError.value = null
|
||||
resetUploads()
|
||||
// 양식을 새로 채우면 다른 메일이다
|
||||
sendKey = createIdempotencyKey()
|
||||
@@ -159,7 +219,7 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
if (sourceMode === 'forward') {
|
||||
const draft = buildForwardDraft(original)
|
||||
subject.value = draft.subject
|
||||
body.value = draft.body
|
||||
draftHtml.value = draft.html
|
||||
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
|
||||
addExisting(draft.attachments)
|
||||
} else {
|
||||
@@ -168,25 +228,54 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
cc.value = draft.cc.map(tokenFromAddress)
|
||||
showCopies.value = cc.value.length > 0
|
||||
subject.value = draft.subject
|
||||
body.value = draft.body
|
||||
draftHtml.value = draft.html
|
||||
// 답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다 — 편집기가 준비되면
|
||||
focusBodyWhenReady = true
|
||||
}
|
||||
// 한도(텍스트 100,000 · 서식 500,000)를 넘는 원문은 편집기가 인용을 줄인다 (계약 2절 "답장·전달 초안의 길이")
|
||||
fitDraft.value = true
|
||||
renewEditor()
|
||||
} catch {
|
||||
// 원문을 못 불러왔다 — 알림은 인터셉터가 띄웠다. 빈 양식으로 쓰게 둔다
|
||||
} finally {
|
||||
if (seq === prepareSeq) preparing.value = false
|
||||
}
|
||||
if (seq !== prepareSeq) return
|
||||
await nextTick()
|
||||
if (sourceMode === 'forward') {
|
||||
// 전달은 받는 사람부터 정한다
|
||||
await nextTick()
|
||||
toField.value?.focus()
|
||||
return
|
||||
}
|
||||
// 답장은 커서를 본문 맨 앞(인용 위)에 둔다
|
||||
bodyRef.value?.focus()
|
||||
bodyRef.value?.setSelectionRange(0, 0)
|
||||
}
|
||||
|
||||
/** 아직 어느 칸에도 초점이 없다 — 사용자가 받는 사람 · 제목에 쓰기 시작했으면 본문으로 옮기지 않는다 (한글 조합이 끊긴다) */
|
||||
function isFocusFree(): boolean {
|
||||
const active = document.activeElement
|
||||
return active === null || active === document.body
|
||||
}
|
||||
|
||||
function onEditorReady() {
|
||||
editorReady.value = true
|
||||
if (!focusBodyWhenReady) return
|
||||
focusBodyWhenReady = false
|
||||
void nextTick(() => {
|
||||
if (isFocusFree()) editorRef.value?.focus('start')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에 붙여 넣거나 끌어다 놓은 파일 — 첨부로 올리고, 이미지를 첨부로 돌렸을 때만 알린다 (디자인 README "본문 이미지 → 첨부 파일").
|
||||
* 사전 검사(형식 · 크기 · 개수)에 걸린 이미지는 올리지 않으므로 알리지 않는다 — 첨부 목록에 실패로 보인다.
|
||||
*/
|
||||
function attachFromBody(files: File[]) {
|
||||
if (files.length === 0) return
|
||||
const accepted = addFiles(files)
|
||||
if (accepted.some(isImageFile)) toast.success(IMAGE_AS_ATTACHMENT_MESSAGE)
|
||||
}
|
||||
|
||||
// 쓰기 화면이 떠 있는 동안 어디에 놓아도 첨부로 올린다 — 끄는 동안 본문 위에 덮개를 보인다
|
||||
const { dragging } = useWindowFileDrop(attachFromBody)
|
||||
|
||||
function commitRecipientInputs() {
|
||||
toField.value?.commit()
|
||||
ccField.value?.commit()
|
||||
@@ -217,14 +306,31 @@ async function onSubmit() {
|
||||
toField.value?.focus()
|
||||
return
|
||||
}
|
||||
if (!canSend.value) return
|
||||
if (!canSend.value || !editorRef.value) return
|
||||
|
||||
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 VAL_001 로 거절한다)
|
||||
const subjectText = subject.value.trim()
|
||||
if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
|
||||
lengthError.value = 'subject'
|
||||
subjectInputRef.value?.focus()
|
||||
return
|
||||
}
|
||||
// 서식 본문과 같은 내용의 텍스트를 함께 보낸다 — 본문이 비었으면 텍스트 메일 (계약 2절 "서식 본문").
|
||||
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
|
||||
const content = editorRef.value.getContentForSend()
|
||||
if (content === null) {
|
||||
lengthError.value = 'body'
|
||||
editorRef.value.focus()
|
||||
return
|
||||
}
|
||||
lengthError.value = null
|
||||
const payload: SendMessagePayload = {
|
||||
to: to.value.map((token) => token.address),
|
||||
cc: cc.value.map((token) => token.address),
|
||||
bcc: bcc.value.map((token) => token.address),
|
||||
subject: subject.value.trim(),
|
||||
body: body.value,
|
||||
subject: subjectText,
|
||||
body: content.text,
|
||||
...(content.html ? { bodyHtml: content.html } : {}),
|
||||
// 새로 올린 파일은 업로드 응답 id, 전달한 원본 첨부는 원본 id 를 그대로 보낸다 (계약 2절 "전달")
|
||||
attachments: uploaded.value.map(({ id, name }) => ({ id, name })),
|
||||
// 스레드 연결은 답장만 — 전달에는 inReplyTo 를 넣지 않는다
|
||||
@@ -268,11 +374,16 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 제목에서 Enter 는 메일을 보내지 않고 본문으로 넘어간다 */
|
||||
/** 제목에서 Enter 는 메일을 보내지 않고 본문으로 넘어간다 — 커서는 본문에 있던 자리 그대로 (처음이면 맨 앞) */
|
||||
function onSubjectEnter(event: KeyboardEvent) {
|
||||
if (event.isComposing) return
|
||||
event.preventDefault()
|
||||
bodyRef.value?.focus()
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 길이 안내는 그 칸을 고치면 내린다 */
|
||||
function clearLengthError(field: 'subject' | 'body') {
|
||||
if (lengthError.value === field) lengthError.value = null
|
||||
}
|
||||
|
||||
function pickFiles() {
|
||||
@@ -389,13 +500,14 @@ watch(allRecipients, () => {
|
||||
<span>{{ recipientErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<div class="compose__row">
|
||||
<div class="compose__row compose-field">
|
||||
<label
|
||||
for="compose-subject"
|
||||
class="compose__label"
|
||||
>제목</label>
|
||||
<input
|
||||
id="compose-subject"
|
||||
ref="subjectInputRef"
|
||||
v-model="subject"
|
||||
type="text"
|
||||
class="compose__subject"
|
||||
@@ -403,20 +515,52 @@ watch(allRecipients, () => {
|
||||
autocomplete="off"
|
||||
:maxlength="MAIL_SUBJECT_MAX_LENGTH"
|
||||
:disabled="preparing"
|
||||
:aria-invalid="lengthError === 'subject'"
|
||||
:aria-describedby="lengthError === 'subject' ? SUBJECT_ERROR_ID : undefined"
|
||||
@keydown.enter="onSubjectEnter"
|
||||
@input="clearLengthError('subject')"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="lengthError === 'subject'"
|
||||
:id="SUBJECT_ERROR_ID"
|
||||
class="compose__alert"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ lengthErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
ref="bodyRef"
|
||||
v-model="body"
|
||||
class="compose__body"
|
||||
aria-label="본문"
|
||||
rows="13"
|
||||
placeholder="내용을 입력하세요"
|
||||
:maxlength="MAIL_BODY_MAX_LENGTH"
|
||||
:disabled="preparing"
|
||||
/>
|
||||
<!-- 편집기를 불러오거나 원문을 채우는 동안에도 자리를 지켜 아래 줄이 뛰지 않게 한다 -->
|
||||
<div class="compose__editor">
|
||||
<MailEditor
|
||||
v-if="!preparing"
|
||||
ref="editorRef"
|
||||
:key="editorKey"
|
||||
:initial-html="draftHtml"
|
||||
:fit-to-limits="fitDraft"
|
||||
:dragging="dragging"
|
||||
:described-by="lengthError === 'body' ? BODY_ERROR_ID : null"
|
||||
@ready="onEditorReady"
|
||||
@files="attachFromBody"
|
||||
@change="clearLengthError('body')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="lengthError === 'body'"
|
||||
:id="BODY_ERROR_ID"
|
||||
class="compose__alert"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ lengthErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<div class="compose__attachments">
|
||||
<div class="compose__attach-bar">
|
||||
@@ -587,33 +731,23 @@ watch(allRecipients, () => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compose__body {
|
||||
/*
|
||||
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300) + 아래 선.
|
||||
* 편집기를 내려받는 동안에도 같은 높이를 둔다. 포커스는 편집 영역의 아래 선이다 (.compose-field, styles/base.css).
|
||||
*/
|
||||
.compose__editor {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 18.75rem;
|
||||
max-width: var(--compose-body-max-width);
|
||||
padding: var(--space-16) var(--content-inline);
|
||||
border: 0;
|
||||
resize: vertical;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
/* 테두리 없는 넓은 입력칸이라 초점 표시는 1px 로 가볍게, 안쪽에 둔다 */
|
||||
.compose__subject:focus-visible,
|
||||
.compose__body:focus-visible {
|
||||
outline-width: var(--border-width);
|
||||
outline-offset: calc(var(--border-width) * -1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(var(--size-editor-toolbar) + var(--size-editor-body-min) + var(--border-width));
|
||||
}
|
||||
|
||||
/* 위 선은 없다 — 편집 영역의 아래 선이 경계다 (디자인 README "글자 입력칸 포커스") */
|
||||
.compose__attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-10) var(--content-inline) var(--space-14);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.compose__attach-bar {
|
||||
|
||||
Reference in New Issue
Block a user