feat(frontend): 읽기 화면 이동 · 끌어다 놓기 · 메일함 옮기기 · 차단할 때 받은편지함 정리 · 예약 메일 지금 보내기

- 읽기 화면 [이동 ▾](옮긴 뒤 다음 메일 · [되돌리기]), 도구 줄은 읽기 칸 폭(컨테이너 쿼리)으로 아이콘만
- [이동 ▾] 메뉴 들여쓰기 · 그 메일이 든 메일함에 하위가 있으면 흐린 줄 · 넘치면 좌우 바꾸기(EditorPopup 메뉴 모두),
  같은 이름이 다른 곳에도 있을 때만 알림에 "상위 › 이름"
- 목록 → 메일함 패널 끌어다 놓기(고른 것 · 대화 줄, 휴지통은 삭제), 메일 화면에 놓은 파일은 무시
- 스팸메일함으로 옮기는 세 길 같게 — 메뉴 · 끌기도 스팸 표시를 붙이고, 꺼내면 스팸 아님
- 메일함 ⋯ 옮기기 창(같은 이름이 있는 곳은 처음부터 막음) · 하위까지 지우기 확인 창
- 차단 창에 받은편지함 정리 칸(같은 탭의 필터 적용 · 스팸메일함 비우기와 함께 돌지 않음), 진행 알림 하나
- 예약 안내 줄 [지금 보내기] — 결과를 모르는 실패는 그 메일을 다시 받아 가르고 확인하지 못한 일은 단정하지 않는다
- 상단 바 좁은 폭에서 [로그아웃]이 꺾이지 않게, 같은 대화 [모두 펼치기] · 펼친 줄 머리 [답장]

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-09 18:37:55 +09:00
parent 1ca210dec4
commit d02a983d57
89 changed files with 7105 additions and 371 deletions
+59
View File
@@ -0,0 +1,59 @@
// 단위 테스트 전용 — 끌어다 놓기 이벤트 (jsdom 에는 DragEvent · DataTransfer 가 없다).
// 브라우저처럼 dragstart 에 넣은 값은 놓을 때만 읽히고(getData), 끄는 동안(dragenter · dragover)에는 종류(types)만 보인다 —
// 테스트도 같은 DataTransfer 를 dragstart · dragover · drop 에 함께 넘긴다. 앱 코드에서 import 하지 않는다
export interface FakeDataTransfer {
types: string[]
files: File[]
effectAllowed: string
dropEffect: string
setData(type: string, value: string): void
getData(type: string): string
/** 파일 밖의 값을 모두 지운다 (브라우저처럼 — 끌기를 시작할 때만 쓸 수 있다) */
clearData(): void
setDragImage(image: Element, x: number, y: number): void
/** setDragImage 로 붙인 그림 (테스트가 본다) */
dragImage: Element | null
}
/** 메일 끌기에 쓰는 빈 DataTransfer — 파일 끌기면 files 를 넘긴다(types 에 'Files') */
export function fakeDataTransfer(files: File[] = []): FakeDataTransfer {
const data = new Map<string, string>()
const transfer: FakeDataTransfer = {
types: files.length > 0 ? ['Files'] : [],
files,
effectAllowed: 'uninitialized',
dropEffect: 'none',
dragImage: null,
setData(type, value) {
data.set(type, value)
if (!transfer.types.includes(type)) transfer.types.push(type)
},
getData(type) {
return data.get(type) ?? ''
},
clearData() {
data.clear()
transfer.types = transfer.types.filter((type) => type === 'Files')
},
setDragImage(image) {
transfer.dragImage = image
},
}
return transfer
}
/** dragstart · dragenter · dragover · dragleave · drop · dragend — bubbles · cancelable, 마우스 자리(clientX · clientY)는 0 */
export function dragEvent(
type: string,
dataTransfer: FakeDataTransfer,
init: { relatedTarget?: EventTarget | null; ctrlKey?: boolean } = {},
): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', { value: dataTransfer })
Object.defineProperty(event, 'relatedTarget', { value: init.relatedTarget ?? null })
Object.defineProperty(event, 'clientX', { value: 0 })
Object.defineProperty(event, 'clientY', { value: 0 })
Object.defineProperty(event, 'ctrlKey', { value: init.ctrlKey ?? false })
return event
}