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:
2026-10-27 07:49:40 +09:00
parent 8fc73aa541
commit f77179a508
63 changed files with 8310 additions and 387 deletions
@@ -71,10 +71,13 @@ describe('useAttachmentUploads', () => {
const serverLimit = 5 * BYTES_PER_MB
const { uploads, stop } = withUploads({ maxBytes: () => serverLimit })
uploads.addFiles([file('작은.pdf', serverLimit), file('큰.pdf', serverLimit + 1)])
const small = file('작은.pdf', serverLimit)
const accepted = uploads.addFiles([small, file('큰.pdf', serverLimit + 1)])
expect(uploads.items.value.map((item) => item.status)).toEqual(['uploading', 'failed'])
expect(uploads.items.value[1]?.errorMessage).toBe(ERROR_CODE_MESSAGES.FILE_002)
// 올리기 시작한 파일만 돌려준다 — 화면은 이것으로 "이미지는 첨부로" 알림을 띄울지 정한다
expect(accepted).toEqual([small])
stop()
})
@@ -129,8 +129,10 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
/**
* 새 파일을 올린다 — 사전 검사(개수·형식·파일당 한도·합계 한도)에 걸리면 올리지 않고 실패로 표시한다.
* 여러 개를 한 번에 고르면 앞에서부터 붙이며 개수·합계를 센다 (넘는 파일만 빠진다).
* @returns 사전 검사를 통과해 올리기 시작한 파일 — 화면이 알림을 띄울지 정하는 데 쓴다
*/
function addFiles(files: Iterable<File>) {
function addFiles(files: Iterable<File>): File[] {
const accepted: File[] = []
for (const file of files) {
const key = takeKey()
const rejected = precheckAttachment(file, limitsFor('upload'), attached.value)
@@ -148,8 +150,12 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
missing: false,
},
]
if (!rejected) void upload(file, key)
if (!rejected) {
accepted.push(file)
void upload(file, key)
}
}
return accepted
}
/**
@@ -0,0 +1,58 @@
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { isFileDrag } from '@/shared/utils/editor-files'
/**
* 화면 어디에 놓아도 파일을 받는다 — 이 화면이 떠 있는 동안 window 에서 파일 끌기를 받는다
* (디자인 README "본문 이미지 → 첨부 파일": 쓰기 화면 어디에 놓아도 첨부로 올린다).
* 받지 않은 곳에 놓으면 브라우저가 그 파일을 열어(페이지 이동) 쓰던 내용을 잃는다.
* - 파일 끌기의 dragover · drop 기본 동작을 막는다 — 글을 끄는 것은 그대로 둔다
* - 다른 곳(본문 편집기)이 이미 처리한 놓기(defaultPrevented)는 다시 넘기지 않는다
* - dragging: 파일을 끌어 창 위에 있는 동안 true — 들어온 요소 수를 세어 자식 사이를 지날 때 깜빡이지 않고,
* 들어온 만큼 나가면(창 밖으로 · Esc 로 그만둠 — 브라우저가 dragleave 를 보낸다) false
*/
export function useWindowFileDrop(onFiles: (files: File[]) => void) {
const depth = ref<number>(0)
const dragging = computed(() => depth.value > 0)
function onDragEnter(event: DragEvent) {
if (!isFileDrag(event)) return
event.preventDefault()
depth.value += 1
}
function onDragOver(event: DragEvent) {
if (!isFileDrag(event)) return
event.preventDefault()
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
}
function onDragLeave(event: DragEvent) {
if (!isFileDrag(event)) return
depth.value = Math.max(0, depth.value - 1)
}
function onDrop(event: DragEvent) {
depth.value = 0
if (event.defaultPrevented || !isFileDrag(event)) return
event.preventDefault()
onFiles(Array.from(event.dataTransfer?.files ?? []))
}
const listeners = [
['dragenter', onDragEnter],
['dragover', onDragOver],
['dragleave', onDragLeave],
['drop', onDrop],
] as const
onMounted(() => {
for (const [type, listener] of listeners) window.addEventListener(type, listener)
})
onBeforeUnmount(() => {
for (const [type, listener] of listeners) window.removeEventListener(type, listener)
})
return { dragging }
}