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
@@ -0,0 +1,37 @@
<script setup lang="ts">
// 서식 편집기(지연 청크)를 내려받지 못했을 때 — 쓰기 화면 defineAsyncComponent 의 errorComponent
// 네트워크가 잠깐 끊기는 등으로 편집기 청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도"를 보인다.
// 다시 시도는 쓰기 화면이 provide 한 함수로 편집기를 새로 만든다 (청크를 다시 받는다).
// 편집기에 넘기던 속성(초안 HTML 등)이 이 컴포넌트로 흘러오므로 화면에 붙이지 않는다 (inheritAttrs: false).
import { inject } from 'vue'
import MailLoadError from '@/components/mail/MailLoadError.vue'
import { MAIL_EDITOR_RETRY } from '@/components/mail/editor/mail-editor-handle'
import { getErrorMessage, type ApiError } from '@/composables/useApi'
defineOptions({ inheritAttrs: false })
interface Props {
/** defineAsyncComponent 가 넘기는 내려받기 오류 — 화면에는 사전 문구만 보인다 */
error?: unknown
}
defineProps<Props>()
const retry = inject(MAIL_EDITOR_RETRY, null)
/** 문구는 에러 코드 사전에서만 온다 */
const LOAD_ERROR: ApiError = { code: 'SYS_001', message: getErrorMessage('SYS_001') }
function onRetry() {
retry?.()
}
</script>
<template>
<MailLoadError
:error="LOAD_ERROR"
leave-label=""
@retry="onRetry"
/>
</template>
@@ -94,7 +94,7 @@ defineExpose({ commit, focus })
</script>
<template>
<div class="recipient-field">
<div class="recipient-field compose-field">
<label
:for="inputId"
class="recipient-field__label"
@@ -182,16 +182,7 @@ defineExpose({ commit, focus })
color: var(--color-text);
}
.recipient-field__input:focus-visible {
outline: none;
}
/* 테두리 없는 넓은 입력 영역이라 쓰기 화면의 제목·본문 칸과 같이 1px 로 가볍게 표시한다 */
.recipient-field__control:focus-within {
outline: var(--border-width) solid var(--color-brand);
outline-offset: var(--focus-ring-offset);
border-radius: var(--radius-xs);
}
/* 포커스는 행 전체의 아래 선으로 보인다 — styles/base.css 의 .compose-field 규칙 */
.recipient-chip {
display: inline-flex;
@@ -0,0 +1,78 @@
<script setup lang="ts">
// 글자 색 · 배경 색 단추의 그림 — 지금 색을 글자 아래 막대(글자 색)나 바탕(배경 색)으로 보인다 (디자인 원본 Compose)
// 색은 SVG 의 fill 속성으로 넣는다 (인라인 style 을 쓰지 않는다).
// fill 에는 형식을 확인한 색(#hex · rgb())만 넣는다 — url(#…) 같은 값이 SVG 그림을 다른 것으로 바꾸지 못하게.
import { computed } from 'vue'
import { REM_BASE_PX } from '@/shared/constants/icons'
import { EDITOR_TEXT_COLORS } from '@/shared/constants/mail-editor'
import { isOutgoingStyleValue } from '@/shared/utils/outgoing-html-rules'
interface Props {
kind: 'text' | 'background'
/** 지금 색 — null 이면 기본(글자 색) · 없음(배경 색) */
color: string | null
size?: number
}
const props = withDefaults(defineProps<Props>(), {
size: 16,
})
/** 글자 색 "기본"의 막대 색 */
const DEFAULT_TEXT_COLOR = EDITOR_TEXT_COLORS[0]?.swatch ?? 'currentColor'
const dimension = computed(() => `${props.size / REM_BASE_PX}rem`)
/** 그릴 색 — 형식이 맞지 않으면 지정 없음과 같게 그린다 */
const safeColor = computed(() =>
props.color !== null && isOutgoingStyleValue('color', props.color) ? props.color : null,
)
</script>
<template>
<svg
class="color-glyph"
:width="dimension"
:height="dimension"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.75"
stroke-linecap="round"
stroke-linejoin="round"
aria-hidden="true"
>
<template v-if="kind === 'text'">
<path d="M6.5 14.5 12 3.5l5.5 11" />
<path d="M8.5 10.5h7" />
<rect
x="4.5"
y="18"
width="15"
height="3"
rx="1"
:fill="safeColor ?? DEFAULT_TEXT_COLOR"
stroke="none"
/>
</template>
<template v-else>
<rect
x="3.5"
y="3.5"
width="17"
height="17"
rx="3"
:fill="safeColor ?? 'none'"
/>
<path d="M8 16.5 12 7.5l4 9" />
<path d="M9.3 13.5h5.4" />
</template>
</svg>
</template>
<style scoped>
.color-glyph {
flex: 0 0 auto;
display: block;
}
</style>
@@ -0,0 +1,214 @@
<script setup lang="ts">
// 글자 색 · 배경 색 고르기 (디자인 원본 ComposeEditor "글자 색 · 배경 색")
// 8색 · 견본 칸 40×40(밀도 예외) · 보이는 색 26 · 고른 색에 ✓ · 고른 색 이름을 위에 글자로도 보인다.
// 색은 SVG 의 fill 속성으로 그린다 (인라인 style 을 쓰지 않는다). 화살표 키로 견본 사이를 옮긴다.
// 한 줄의 견본 수(COLUMNS)는 여기 한 곳에서 정한다 — 줄을 나눠 그리므로 CSS 는 그 수를 모른다.
import { computed } from 'vue'
import AppIcon from '@/components/AppIcon.vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
import { EDITOR_OTHER_COLOR_NAME, type EditorColor } from '@/shared/constants/mail-editor'
interface Props {
title: string
palette: readonly EditorColor[]
/** 지금 색 — 팔레트 밖이면 null */
current: EditorColor | null
trigger: HTMLElement | null
align?: 'start' | 'end'
/** 마우스로 열었다 — 견본 대신 팝오버 자체에 초점 (EditorPopup) */
focusContent?: boolean
}
interface Emits {
(e: 'pick', color: EditorColor): void
(e: 'close', restoreFocus: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
align: 'start',
focusContent: false,
})
const emit = defineEmits<Emits>()
/** 한 줄에 놓는 견본 수 */
const COLUMNS = 4
/** 견본 모서리 — 26 안의 6 */
const SWATCH_RADIUS = 6
const SWATCH_BORDER = 'rgba(16, 24, 40, 0.14)'
const currentName = computed(() => props.current?.name ?? EDITOR_OTHER_COLOR_NAME)
/** 견본을 한 줄에 COLUMNS 개씩 */
const rows = computed(() =>
Array.from({ length: Math.ceil(props.palette.length / COLUMNS) }, (_, row) =>
props.palette.slice(row * COLUMNS, (row + 1) * COLUMNS),
),
)
const MOVES: Readonly<Record<string, number>> = {
ArrowRight: 1,
ArrowLeft: -1,
ArrowDown: COLUMNS,
ArrowUp: -COLUMNS,
}
function onKeydown(event: KeyboardEvent) {
const move = MOVES[event.key]
if (move === undefined) return
const root = event.currentTarget as HTMLElement
const buttons = Array.from(root.querySelectorAll<HTMLButtonElement>('.swatch'))
const index = buttons.indexOf(document.activeElement as HTMLButtonElement)
event.preventDefault()
// 팝오버 자체에 초점이 있으면(마우스로 열었다) 먼저 고른 색으로 들어간다
if (index < 0) {
;(root.querySelector<HTMLButtonElement>(".swatch[aria-pressed='true']") ?? buttons[0])?.focus()
return
}
const next = Math.min(buttons.length - 1, Math.max(0, index + move))
buttons[next]?.focus()
}
</script>
<template>
<EditorPopup
role="dialog"
:label="title"
:trigger="trigger"
:align="align"
initial-focus=".swatch[aria-pressed='true']"
:focus-content="focusContent"
class="color-picker"
@keydown="onKeydown"
@close="(restore) => emit('close', restore)"
>
<div class="color-picker__head">
<span class="color-picker__title">{{ title }}</span>
<span class="color-picker__current">{{ currentName }}</span>
</div>
<div class="color-picker__grid">
<div
v-for="(row, rowIndex) in rows"
:key="rowIndex"
class="color-picker__row"
>
<button
v-for="color in row"
:key="color.id"
type="button"
class="swatch"
:aria-label="color.name"
:aria-pressed="current?.id === color.id"
:title="color.name"
@click="emit('pick', color)"
>
<svg
class="swatch__color"
viewBox="0 0 26 26"
aria-hidden="true"
>
<rect
x="0.5"
y="0.5"
width="25"
height="25"
:rx="SWATCH_RADIUS"
:fill="color.swatch"
:stroke="SWATCH_BORDER"
/>
</svg>
<AppIcon
v-if="current?.id === color.id"
name="check"
:size="14"
:stroke-width="2.5"
class="swatch__mark"
:class="`swatch__mark--${color.checkTone}`"
/>
<AppIcon
v-else-if="color.slashed"
name="slash"
:size="18"
:stroke-width="2"
class="swatch__mark swatch__mark--none"
/>
</button>
</div>
</div>
</EditorPopup>
</template>
<style scoped>
/* 폭은 견본 줄이 정한다 — 4칸 × 40 + 간격 + 여백 = 186px */
.color-picker {
gap: var(--space-8);
padding: var(--space-10);
}
.color-picker__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--space-8);
padding: 0 var(--space-2);
}
.color-picker__title {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
.color-picker__current {
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.color-picker__grid,
.color-picker__row {
display: flex;
gap: var(--space-2);
}
.color-picker__grid {
flex-direction: column;
}
.swatch {
position: relative;
display: inline-flex;
align-items: center;
justify-content: center;
/* 견본 칸 40×40 — 밀도 예외 (tokens.css --size-editor-swatch-cell) */
width: var(--size-editor-swatch-cell);
height: var(--size-editor-swatch-cell);
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: transparent;
}
.swatch:hover {
background: var(--color-surface-muted);
}
.swatch__color {
width: var(--size-editor-swatch);
height: var(--size-editor-swatch);
}
.swatch__mark {
position: absolute;
}
.swatch__mark--light {
color: var(--color-on-brand);
}
.swatch__mark--dark {
color: var(--color-text);
}
.swatch__mark--none {
color: var(--color-danger);
}
</style>
@@ -0,0 +1,236 @@
<script setup lang="ts">
// 링크 넣기 · 고치기 팝오버 (디자인 원본 ComposeEditor "링크") — 폭 300
// 주소 · 표시할 글자(비우면 주소). 새 링크는 [취소] [넣기], 이미 있는 링크는 [링크 빼기] … [취소] [적용].
// Enter = 적용 · Esc = 닫고 본문으로. 주소는 http · https · mailto 만 받는다 (그 밖은 칸 아래에 사전 문구).
// 테두리가 있는 입력칸이라 포커스는 전역 고리를 쓴다 (디자인 README "글자 입력칸 포커스").
import { computed, ref } from 'vue'
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 { normalizeLinkHref } from '@/shared/utils/link-href'
interface Props {
/** 이미 있는 링크를 고친다 — "링크 빼기"를 보이고 단추 이름이 "적용"이 된다 */
editing: boolean
initialHref?: string
initialText?: string
trigger: HTMLElement | null
align?: 'start' | 'end'
}
interface Emits {
(e: 'apply', value: LinkFormValue): void
(e: 'remove'): void
(e: 'close', restoreFocus: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
initialHref: '',
initialText: '',
align: 'end',
})
const emit = defineEmits<Emits>()
/** 화면에 하나뿐인 편집기 안의 팝오버 — 고정 id 로 이름표를 잇는다 */
const URL_INPUT_ID = 'compose-link-url'
const TEXT_INPUT_ID = 'compose-link-text'
const ERROR_ID = 'compose-link-error'
const address = ref<string>(props.initialHref)
const text = ref<string>(props.initialText)
const invalid = ref<boolean>(false)
const title = computed(() => (props.editing ? '링크 고치기' : '링크 넣기'))
const submitLabel = computed(() => (props.editing ? '적용' : '넣기'))
/** 문구는 에러 코드 사전에서만 온다 — 형식 오류(VAL_001) */
const invalidMessage = getErrorMessage('VAL_001')
function submit() {
const href = normalizeLinkHref(address.value)
if (href === null) {
invalid.value = true
return
}
const shown = text.value.trim() === '' ? address.value.trim() : text.value
emit('apply', { href, text: shown })
}
function onEnter(event: KeyboardEvent) {
// 한글 조합 중의 Enter 는 글자를 확정할 뿐이다
if (event.isComposing) return
event.preventDefault()
submit()
}
function onAddressInput() {
invalid.value = false
}
</script>
<template>
<EditorPopup
role="dialog"
:label="title"
:trigger="trigger"
:align="align"
:initial-focus="`#${URL_INPUT_ID}`"
class="link-form"
@close="(restore) => emit('close', restore)"
>
<div class="link-form__field">
<label
:for="URL_INPUT_ID"
class="link-form__label"
>주소</label>
<input
:id="URL_INPUT_ID"
v-model="address"
type="url"
class="link-form__input"
placeholder="https://"
autocomplete="off"
spellcheck="false"
:aria-invalid="invalid"
:aria-describedby="invalid ? ERROR_ID : undefined"
@input="onAddressInput"
@keydown.enter="onEnter"
>
<p
v-if="invalid"
:id="ERROR_ID"
class="link-form__error"
role="alert"
>
{{ invalidMessage }}
</p>
</div>
<div class="link-form__field">
<label
:for="TEXT_INPUT_ID"
class="link-form__label"
>표시할 글자</label>
<input
:id="TEXT_INPUT_ID"
v-model="text"
type="text"
class="link-form__input"
placeholder="비우면 주소를 그대로 보여 줍니다"
autocomplete="off"
@keydown.enter="onEnter"
>
</div>
<div class="link-form__actions">
<button
v-if="editing"
type="button"
class="link-form__button link-form__remove"
@click="emit('remove')"
>
<AppIcon name="unlink" />
<span>링크 빼기</span>
</button>
<span class="link-form__spacer" />
<button
type="button"
class="link-form__button"
@click="emit('close', true)"
>
취소
</button>
<button
type="button"
class="link-form__button link-form__submit"
@click="submit"
>
{{ submitLabel }}
</button>
</div>
</EditorPopup>
</template>
<style scoped>
.link-form {
width: 18.75rem; /* 300px */
max-width: calc(100vw - var(--space-24));
gap: var(--space-10);
padding: var(--space-14);
}
.link-form__field {
display: flex;
flex-direction: column;
gap: var(--space-4);
}
.link-form__label {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-strong);
}
.link-form__input {
min-height: var(--size-row-min);
padding: 0 var(--space-12);
border: var(--border-width) solid var(--color-border-input);
border-radius: var(--radius-md);
background: var(--color-surface);
color: var(--color-text);
font-size: var(--font-size-body);
}
.link-form__input[aria-invalid='true'] {
border-color: var(--color-danger);
}
.link-form__error {
margin: 0;
font-size: var(--font-size-small);
color: var(--color-danger);
}
.link-form__actions {
display: flex;
align-items: center;
gap: var(--space-6);
}
.link-form__spacer {
flex: 1 1 auto;
}
.link-form__button {
display: inline-flex;
align-items: center;
gap: var(--space-6);
min-height: var(--size-touch);
padding: 0 var(--space-12);
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
font-weight: 500;
}
.link-form__button:hover {
background: var(--color-surface-muted);
}
/* 아이콘이 입력칸 왼쪽 선에 오게 바깥으로 6 당긴다 (디자인 원본) */
.link-form__remove {
margin-left: calc(var(--space-6) * -1);
padding: 0 var(--space-8) 0 var(--space-6);
}
.link-form__submit {
padding: 0 var(--space-16);
background: var(--color-brand);
color: var(--color-on-brand);
font-weight: 600;
}
.link-form__submit:hover {
background: var(--color-brand-strong);
}
</style>
@@ -0,0 +1,257 @@
<script setup lang="ts">
// 툴바 메뉴 — 글꼴 · 글자 크기 · 더보기 (디자인 원본 ComposeEditor "더보기 메뉴" · "글꼴 · 글자 크기")
// 항목 높이 40 · 아이콘 16 · 이름 · 단축키(흐린 글자) · 켜진 것은 오른쪽 ✓ · 한 단계 더 여는 것은 ›
import AppIcon from '@/components/AppIcon.vue'
import EditorColorGlyph from '@/components/mail/editor/EditorColorGlyph.vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
import type { EditorMenuItem } from '@/components/mail/editor/editor-menu'
interface Props {
label: string
items: readonly EditorMenuItem[]
trigger: HTMLElement | null
align?: 'start' | 'end'
/** 마우스로 열었다 — 항목 대신 메뉴 자체에 초점 (EditorPopup) */
focusContent?: boolean
}
interface Emits {
/** viaKeyboard — Enter · Space 로 골랐다 (한 단계 더 여는 메뉴도 키보드로 이어 쓰게) */
(e: 'select', id: string, viaKeyboard: boolean): void
(e: 'close', restoreFocus: boolean): void
}
withDefaults(defineProps<Props>(), {
align: 'start',
focusContent: false,
})
const emit = defineEmits<Emits>()
function isToggle(role: string | undefined): boolean {
return role === 'menuitemcheckbox' || role === 'menuitemradio'
}
</script>
<template>
<EditorPopup
role="menu"
:label="label"
:trigger="trigger"
:align="align"
:focus-content="focusContent"
@close="(restore) => emit('close', restore)"
>
<template
v-for="item in items"
:key="item.id"
>
<hr
v-if="item.separator"
class="menu__separator"
>
<button
v-else
type="button"
class="menu__item"
:role="item.role ?? 'menuitem'"
:aria-checked="isToggle(item.role) ? Boolean(item.checked) : undefined"
:aria-haspopup="item.opens"
:disabled="item.disabled"
@click="(event: MouseEvent) => emit('select', item.id, event.detail === 0)"
>
<AppIcon
v-if="item.icon"
:name="item.icon"
class="menu__icon"
/>
<EditorColorGlyph
v-else-if="item.colorGlyph"
:kind="item.colorGlyph.kind"
:color="item.colorGlyph.color"
class="menu__icon"
/>
<span :class="['menu__label', item.preview ? `preview--${item.preview}` : '']">{{ item.label }}</span>
<span
v-if="item.badge"
class="menu__badge"
>{{ item.badge }}</span>
<span
v-if="item.hint"
class="menu__hint"
>{{ item.hint }}</span>
<AppIcon
v-if="item.opens"
name="chevron-right"
:size="14"
class="menu__more"
/>
<span
v-else
class="menu__check"
>
<AppIcon
v-if="item.checked"
name="check"
:stroke-width="2"
/>
</span>
</button>
</template>
</EditorPopup>
</template>
<style scoped>
.menu__item {
display: flex;
align-items: center;
gap: var(--space-10);
width: 100%;
/* 항목 높이 40 — 밀도 예외 (tokens.css --size-editor-menu-item) */
min-height: var(--size-editor-menu-item);
padding: 0 var(--space-10);
border: 0;
border-radius: var(--radius-sm);
background: transparent;
color: var(--color-text);
text-align: left;
}
.menu__item:hover:not(:disabled) {
background: var(--color-surface-muted);
}
.menu__item:disabled {
opacity: 0.5;
cursor: default;
}
.menu__icon {
color: var(--color-text-secondary);
}
.menu__label {
flex: 1 1 auto;
min-width: 0;
}
.menu__badge,
.menu__hint {
flex: 0 0 auto;
font-family: var(--font-family-base);
font-size: var(--font-size-small);
color: var(--color-text-muted);
}
.menu__more {
color: var(--color-text-muted);
}
.menu__check {
flex: 0 0 auto;
display: inline-flex;
width: 1rem;
height: 1rem;
color: var(--color-brand-strong);
}
.menu__separator {
width: 100%;
height: var(--border-width);
margin: var(--space-4) 0;
border: 0;
background: var(--color-border-subtle);
}
/*
* 미리보기 — 목록의 이름은 그 글꼴 · 그 크기로 보인다.
* 글꼴 값은 shared/constants/mail-editor.ts 의 EDITOR_FONTS 와 같다 (CSS 는 TS 상수를 읽지 못해 같은 값을 적는다).
*/
.preview--font-malgun {
font-family: var(--font-family-mail-body);
font-size: var(--font-size-body);
}
.preview--font-gulim {
font-family: Gulim, '굴림', sans-serif;
font-size: var(--font-size-body);
}
.preview--font-dotum {
font-family: Dotum, '돋움', sans-serif;
font-size: var(--font-size-body);
}
.preview--font-batang {
font-family: Batang, '바탕', serif;
font-size: var(--font-size-body);
}
.preview--font-arial {
font-family: Arial, sans-serif;
font-size: var(--font-size-body);
}
.preview--font-verdana {
font-family: Verdana, sans-serif;
font-size: var(--font-size-body);
}
.preview--font-georgia {
font-family: Georgia, serif;
font-size: var(--font-size-body);
}
.preview--font-times {
font-family: 'Times New Roman', serif;
font-size: var(--font-size-body);
}
.preview--font-courier {
font-family: 'Courier New', monospace;
font-size: var(--font-size-body);
}
.preview--size-12,
.preview--size-13,
.preview--size-14,
.preview--size-16,
.preview--size-18,
.preview--size-20,
.preview--size-24,
.preview--size-32 {
line-height: 1.1;
font-variant-numeric: tabular-nums;
}
.preview--size-12 {
font-size: 0.75rem;
}
.preview--size-13 {
font-size: 0.8125rem;
}
.preview--size-14 {
font-size: 0.875rem;
}
.preview--size-16 {
font-size: 1rem;
}
.preview--size-18 {
font-size: 1.125rem;
}
.preview--size-20 {
font-size: 1.25rem;
}
.preview--size-24 {
font-size: 1.5rem;
}
.preview--size-32 {
font-size: 2rem;
}
</style>
@@ -0,0 +1,161 @@
<script setup lang="ts">
// 툴바 메뉴 · 팝오버 공통 틀 (디자인 README "메뉴·팝오버 공통")
// 흰 바탕 · 테두리 · 모서리 10 · 그림자, 툴바 아래 선 바로 밑에서 연다.
// - Esc: 닫고 초점을 되돌린다(어디로 갈지는 연 쪽이 정한다) · 바깥을 누르거나 초점이 밖으로 나가면 닫는다(초점은 그대로)
// - 메뉴(role=menu): 위·아래 화살표 · Home · End 로 항목을 옮기고, Tab 은 메뉴를 닫는다
// - 마우스로 열었으면(focusContent) 항목이 아니라 팝오버 자체에 초점을 둔다 — 고른 항목에 키보드 고리가 뜨지 않게.
// 화살표를 누르면 그때 항목으로 들어간다 (키보드로 열면 처음부터 항목에 초점)
import { nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
interface Props {
role: 'menu' | 'dialog'
label: string
/** 연 단추 — 바깥 누름 판정에 쓴다 */
trigger: HTMLElement | null
/** 연 단추의 어느 쪽에 맞출지 — 툴바 오른쪽 끝(링크·더보기)은 end */
align?: 'start' | 'end'
/** 열릴 때 초점을 줄 요소 (선택자). 없으면 켜진 항목, 그다음 첫 항목 */
initialFocus?: string
/** 마우스로 열었다 — 팝오버 자체에 초점을 둔다 */
focusContent?: boolean
}
interface Emits {
(e: 'close', restoreFocus: boolean): void
}
const props = withDefaults(defineProps<Props>(), {
align: 'start',
initialFocus: '',
focusContent: false,
})
const emit = defineEmits<Emits>()
const rootRef = ref<HTMLElement | null>(null)
const MENU_ITEM = '[role^="menuitem"]:not(:disabled)'
const FOCUSABLE = 'button:not(:disabled), input:not(:disabled), [tabindex="0"]'
function menuItems(): HTMLElement[] {
return Array.from(rootRef.value?.querySelectorAll<HTMLElement>(MENU_ITEM) ?? [])
}
function focusInitial() {
const root = rootRef.value
if (!root) return
if (props.focusContent) {
root.focus()
return
}
const target =
(props.initialFocus ? root.querySelector<HTMLElement>(props.initialFocus) : null) ??
(props.role === 'menu'
? (root.querySelector<HTMLElement>(`${MENU_ITEM}[aria-checked="true"]`) ?? menuItems()[0])
: root.querySelector<HTMLElement>(FOCUSABLE))
target?.focus()
}
function moveInMenu(event: KeyboardEvent) {
const items = menuItems()
if (items.length === 0) return
const current = items.indexOf(document.activeElement as HTMLElement)
const last = items.length - 1
const next: Record<string, number> = {
ArrowDown: current < 0 || current === last ? 0 : current + 1,
ArrowUp: current <= 0 ? last : current - 1,
Home: 0,
End: last,
}
const index = next[event.key]
if (index === undefined) return
event.preventDefault()
items[index]?.focus()
}
function onKeydown(event: KeyboardEvent) {
if (event.key === 'Escape') {
event.preventDefault()
event.stopPropagation()
emit('close', true)
return
}
if (props.role !== 'menu') return
if (event.key === 'Tab') {
emit('close', false)
return
}
moveInMenu(event)
}
function isOutside(target: EventTarget | null): boolean {
if (!(target instanceof Node)) return false
return !rootRef.value?.contains(target) && !props.trigger?.contains(target)
}
function onDocumentPointerDown(event: PointerEvent) {
if (isOutside(event.target)) emit('close', false)
}
/** 초점이 팝오버 밖(본문 · 다른 칸)으로 나가면 닫는다 — 팝오버 안의 빈 곳을 누른 것(relatedTarget 없음)은 그대로 */
function onFocusOut(event: FocusEvent) {
if (isOutside(event.relatedTarget)) emit('close', false)
}
onMounted(() => {
document.addEventListener('pointerdown', onDocumentPointerDown, true)
void nextTick(focusInitial)
})
onBeforeUnmount(() => {
document.removeEventListener('pointerdown', onDocumentPointerDown, true)
})
</script>
<template>
<div
ref="rootRef"
:role="role"
:aria-label="label"
:class="['editor-popup', `editor-popup--${align}`, `editor-popup--${role}`]"
tabindex="-1"
data-editor-popup
@keydown="onKeydown"
@focusout="onFocusOut"
>
<slot />
</div>
</template>
<style scoped>
.editor-popup {
position: absolute;
/* 툴바 아래 선 바로 밑 — 단추(44)가 툴바(52) 안 위아래 4 에 놓여 있다 */
top: calc(100% + var(--space-4));
z-index: 30;
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);
color: var(--color-text);
/* 휴대폰처럼 낮은 화면에서는 안에서 스크롤한다 */
max-height: min(35rem, 70vh);
overflow-y: auto;
}
/* 팝오버 자체의 초점은 보이지 않는다 — 항목으로 들어가면 항목에 고리가 뜬다 */
.editor-popup:focus,
.editor-popup:focus-visible {
outline: none;
}
.editor-popup--start {
left: 0;
}
.editor-popup--end {
right: 0;
}
</style>
@@ -0,0 +1,186 @@
<script setup lang="ts">
// 표 넣기 — 8열 × 6행 격자에서 크기를 고른다 (디자인 원본 ComposeEditor "표 넣기")
// 칸 22 · 간격 3 (밀도 예외 — 사용자 확정). 마우스는 칸 위로 움직여 고르고 누르면 넣는다.
// 키보드는 화살표로 크기를 바꾸고 Enter 로 넣는다 — 지금 고른 끝 칸 하나만 Tab 으로 들어간다.
// 격자 크기는 TABLE_PICKER_COLUMNS · ROWS 한 곳에서 정한다 — 줄을 나눠 그리므로 CSS 는 그 수를 모른다.
import { computed, nextTick, ref } from 'vue'
import EditorPopup from '@/components/mail/editor/EditorPopup.vue'
import type { TableSize } from '@/components/mail/editor/editor-menu'
import {
TABLE_PICKER_COLUMNS,
TABLE_PICKER_DEFAULT,
TABLE_PICKER_ROWS,
} from '@/shared/constants/mail-editor'
interface Props {
trigger: HTMLElement | null
align?: 'start' | 'end'
/** 마우스로 열었다 — 칸 대신 팝오버 자체에 초점 (EditorPopup) */
focusContent?: boolean
}
interface Emits {
(e: 'pick', size: TableSize): void
(e: 'close', restoreFocus: boolean): void
}
withDefaults(defineProps<Props>(), {
align: 'end',
focusContent: false,
})
const emit = defineEmits<Emits>()
const rows = ref<number>(TABLE_PICKER_DEFAULT.rows)
const cols = ref<number>(TABLE_PICKER_DEFAULT.cols)
const gridRef = ref<HTMLElement | null>(null)
interface Cell {
key: string
row: number
col: number
}
/** 격자 — 줄마다 칸 (1부터 센다) */
const gridRows: readonly (readonly Cell[])[] = Array.from({ length: TABLE_PICKER_ROWS }, (_, rowIndex) =>
Array.from({ length: TABLE_PICKER_COLUMNS }, (_, colIndex) => {
const row = rowIndex + 1
const col = colIndex + 1
return { key: `${row}-${col}`, row, col }
}),
)
const sizeLabel = computed(() => `${rows.value}행 × ${cols.value}열`)
function isSelected(cell: Cell): boolean {
return cell.row <= rows.value && cell.col <= cols.value
}
function isCorner(cell: Cell): boolean {
return cell.row === rows.value && cell.col === cols.value
}
function choose(cell: Cell) {
rows.value = cell.row
cols.value = cell.col
}
function clamp(value: number, max: number): number {
return Math.min(max, Math.max(1, value))
}
async function onKeydown(event: KeyboardEvent) {
const moves: Record<string, [number, number]> = {
ArrowRight: [0, 1],
ArrowLeft: [0, -1],
ArrowDown: [1, 0],
ArrowUp: [-1, 0],
}
const move = moves[event.key]
if (!move) return
event.preventDefault()
const corner = () => gridRef.value?.querySelector<HTMLButtonElement>(`[data-cell="${rows.value}-${cols.value}"]`)
// 팝오버 자체에 초점이 있으면(마우스로 열었다) 먼저 지금 고른 칸으로 들어간다
if (!(document.activeElement instanceof HTMLElement) || !document.activeElement.dataset.cell) {
corner()?.focus()
return
}
rows.value = clamp(rows.value + move[0], TABLE_PICKER_ROWS)
cols.value = clamp(cols.value + move[1], TABLE_PICKER_COLUMNS)
await nextTick()
corner()?.focus()
}
</script>
<template>
<EditorPopup
role="dialog"
label="표 넣기"
:trigger="trigger"
:align="align"
initial-focus=".table-picker__cell[tabindex='0']"
:focus-content="focusContent"
class="table-picker"
@keydown="onKeydown"
@close="(restore) => emit('close', restore)"
>
<span class="table-picker__title">표 넣기</span>
<div
ref="gridRef"
class="table-picker__grid"
>
<div
v-for="(cellsInRow, rowIndex) in gridRows"
:key="rowIndex"
class="table-picker__row"
>
<button
v-for="cell in cellsInRow"
:key="cell.key"
type="button"
class="table-picker__cell"
:class="{ 'table-picker__cell--selected': isSelected(cell) }"
:data-cell="cell.key"
:tabindex="isCorner(cell) ? 0 : -1"
:aria-label="`${cell.row}행 ${cell.col}열 표 넣기`"
@mouseenter="choose(cell)"
@focus="choose(cell)"
@click="emit('pick', { rows: cell.row, cols: cell.col })"
/>
</div>
</div>
<span
class="table-picker__size"
aria-live="polite"
>{{ sizeLabel }}</span>
</EditorPopup>
</template>
<style scoped>
.table-picker {
gap: var(--space-8);
padding: var(--space-12);
}
.table-picker__title {
font-size: var(--font-size-label);
font-weight: 600;
color: var(--color-text-secondary);
}
/* 칸 22 · 간격 3 — 밀도 예외 (tokens.css --size-editor-table-cell) */
.table-picker__grid,
.table-picker__row {
display: flex;
gap: var(--editor-table-cell-gap);
}
.table-picker__grid {
flex-direction: column;
}
.table-picker__cell {
width: var(--size-editor-table-cell);
height: var(--size-editor-table-cell);
padding: 0;
border: var(--border-width) solid var(--color-border-strong);
border-radius: var(--space-3);
background: var(--color-surface);
}
.table-picker__cell--selected {
border-color: var(--color-brand);
background: var(--color-brand-subtle);
}
/* 칸 사이가 3px 라 고리를 바짝 붙인다 */
.table-picker__cell:focus-visible {
outline-offset: 0;
}
.table-picker__size {
font-size: var(--font-size-label);
color: var(--color-text-strong);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,128 @@
<script setup lang="ts">
// 본문 서식 툴바 단추 한 개 (디자인 원본 Compose — 아이콘 단추 40×44 · 글꼴 108×44 · 글자 크기 56×44)
// 상태: 올림 · 켜짐(aria-pressed) · 메뉴 열림(aria-expanded) · 비활성(50%) — 포커스는 전역 고리.
// 마우스로 눌러도 본문의 커서·선택이 그대로 있게 mousedown 기본 동작(초점 옮기기)을 막는다.
import type { EditorShortcut } from '@/shared/constants/mail-editor'
interface Props {
/** 툴바 안의 이름 — 화살표 이동 · 팝오버 자리 찾기에 쓴다 */
tool: string
/** 읽어 주는 이름 — 값을 고르는 단추는 지금 값까지 ("글꼴: 맑은 고딕") */
label: string
/** 툴팁 — 비우면 label */
title?: string
shortcut?: EditorShortcut | null
/** 켜고 끄는 단추면 지금 상태 (아니면 null) */
pressed?: boolean | null
/** 메뉴·팝오버를 여는 단추 */
haspopup?: 'menu' | 'dialog' | null
expanded?: boolean
disabled?: boolean
/** 로빙 tabindex — 툴바에서 Tab 으로 들어올 단추 하나만 true */
focusable?: boolean
/** 글자를 보이는 넓은 단추 */
variant?: 'icon' | 'font' | 'size'
}
interface Emits {
(e: 'press'): void
}
const props = withDefaults(defineProps<Props>(), {
title: '',
shortcut: null,
pressed: null,
haspopup: null,
expanded: false,
disabled: false,
focusable: false,
variant: 'icon',
})
const emit = defineEmits<Emits>()
function tooltip(): string {
const base = props.title || props.label
return props.shortcut ? `${base} (${props.shortcut.label})` : base
}
</script>
<template>
<button
type="button"
:class="['tool', `tool--${variant}`]"
:data-tool="tool"
:aria-label="label"
:aria-pressed="pressed ?? undefined"
:aria-haspopup="haspopup ?? undefined"
:aria-expanded="haspopup ? expanded : undefined"
:aria-keyshortcuts="shortcut?.aria"
:title="tooltip()"
:disabled="disabled"
:tabindex="focusable ? 0 : -1"
@mousedown.prevent
@click="emit('press')"
>
<slot />
</button>
</template>
<style scoped>
.tool {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
justify-content: center;
/* 아이콘 단추 40×44 — 폭 40 은 밀도 예외 (tokens.css --size-editor-tool) */
width: var(--size-editor-tool);
height: var(--size-touch);
padding: 0;
border: 0;
border-radius: var(--radius-md);
background: transparent;
color: var(--color-text-strong);
}
.tool:hover,
.tool[aria-expanded='true'] {
background: var(--color-surface-muted);
color: var(--color-text);
}
/* 켜짐 — 옅은 바탕만으로는 3:1 이 안 돼 안쪽 1px 선을 둔다 (디자인 README) */
.tool[aria-pressed='true'] {
background: var(--color-brand-subtle);
color: var(--color-brand-strong);
box-shadow: inset 0 0 0 var(--border-width) var(--color-brand);
}
.tool[aria-pressed='true']:hover {
background: var(--color-editor-pressed-hover);
color: var(--color-brand-strong);
}
.tool:disabled,
.tool:disabled:hover {
opacity: 0.5;
cursor: default;
background: transparent;
color: var(--color-text-strong);
}
/* 글꼴 · 글자 크기 — 글자 + ▾ */
.tool--font,
.tool--size {
justify-content: flex-start;
gap: var(--space-4);
padding: 0 var(--space-8) 0 var(--space-12);
font-weight: 500;
}
.tool--font {
width: var(--size-editor-font-button);
}
.tool--size {
width: var(--size-editor-size-button);
font-variant-numeric: tabular-nums;
}
</style>
@@ -0,0 +1,333 @@
import type { JSONContent } from '@tiptap/core'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import { nextTick } from 'vue'
import MailEditor from '@/components/mail/editor/MailEditor.vue'
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
import { editorOf, installProseMirrorDom } from '@/test-utils/prosemirror-dom'
type EditorWrapper = VueWrapper<InstanceType<typeof MailEditor>>
/** ResizeObserver 대역 — 테스트가 폭을 바꾼 뒤 직접 알린다 */
class FakeResizeObserver {
static instances: FakeResizeObserver[] = []
constructor(private readonly callback: ResizeObserverCallback) {
FakeResizeObserver.instances.push(this)
}
observe() {}
disconnect() {}
unobserve() {}
static notify() {
FakeResizeObserver.instances.forEach((observer) =>
observer.callback([], observer as unknown as ResizeObserver),
)
}
}
describe('EditorToolbar (본문 서식 툴바)', () => {
let wrapper: EditorWrapper | null = null
beforeAll(installProseMirrorDom)
afterEach(() => {
wrapper?.unmount()
wrapper = null
FakeResizeObserver.instances = []
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
/** 처음 내용은 실행 취소 기록에 남지 않는다 (답장 초안처럼) */
function mountEditor(content = '<p>안녕하세요 반갑습니다</p>'): EditorWrapper {
wrapper = mount(MailEditor, { props: { initialHtml: content }, attachTo: document.body })
return wrapper
}
/** 문서에 남은 글자 서식(textStyle) 값 */
function textStyles(view: EditorWrapper): Record<string, unknown>[] {
const styles: Record<string, unknown>[] = []
const walk = (node: JSONContent) => {
node.marks?.forEach((mark) => {
if (mark.type === 'textStyle') styles.push(mark.attrs ?? {})
})
node.content?.forEach(walk)
}
walk(editorOf(view.element).getJSON())
return styles
}
const tool = (view: EditorWrapper, id: string) => view.get(`[data-tool="${id}"]`)
/** TipTap 은 다음 화면 그리기 때 본문에 초점을 준다 */
const nextFrame = () => new Promise<void>((resolve) => requestAnimationFrame(() => resolve()))
function select(view: EditorWrapper, from: number, to: number) {
editorOf(view.element).commands.setTextSelection({ from, to })
}
it('Tab 으로 들어올 단추는 하나뿐이다 — 화살표 · Home · End 로 옮기고 Esc 는 본문으로', async () => {
const view = mountEditor()
await nextTick()
const focusable = view.findAll('[role="toolbar"] [tabindex="0"]')
// 실행 취소 기록이 없어 실행 취소 · 다시 실행은 비활성 — 첫 단추는 글꼴
expect(focusable).toHaveLength(1)
expect(focusable[0]?.attributes('data-tool')).toBe('font')
const font = tool(view, 'font').element as HTMLElement
font.focus()
await tool(view, 'font').trigger('keydown', { key: 'ArrowRight' })
expect(document.activeElement?.getAttribute('data-tool')).toBe('size')
await tool(view, 'size').trigger('keydown', { key: 'End' })
expect(document.activeElement?.getAttribute('data-tool')).toBe('more')
await tool(view, 'more').trigger('keydown', { key: 'ArrowRight' })
expect(document.activeElement?.getAttribute('data-tool')).toBe('font')
await tool(view, 'font').trigger('keydown', { key: 'Escape' })
await nextFrame()
expect(document.activeElement?.id).toBe('compose-body')
})
it('본문에서 Alt+F10 → 툴바', async () => {
const view = mountEditor()
editorOf(view.element).commands.focus()
await view.get('.ProseMirror').trigger('keydown', { key: 'F10', altKey: true })
expect(document.activeElement?.closest('[role="toolbar"]')).not.toBeNull()
})
it('굵게 — 고른 글자에 적용하고 켜짐(aria-pressed)으로 보인다. 실행 취소가 켜진다', async () => {
const view = mountEditor()
select(view, 1, 6)
await nextTick()
expect(tool(view, 'bold').attributes('aria-pressed')).toBe('false')
expect(tool(view, 'undo').attributes('disabled')).toBeDefined()
await tool(view, 'bold').trigger('click')
expect(editorOf(view.element).getHTML()).toContain('<strong>안녕하세요</strong>')
expect(tool(view, 'bold').attributes('aria-pressed')).toBe('true')
expect(tool(view, 'bold').attributes('aria-keyshortcuts')).toBe('Control+B')
expect(tool(view, 'undo').attributes('disabled')).toBeUndefined()
})
it('글꼴 · 글자 크기 — 목록에서 고르면 적용하고 단추 이름이 바뀐다. 기본(맑은 고딕 · 14)을 고르면 지정을 지운다', async () => {
const view = mountEditor()
select(view, 1, 6)
await tool(view, 'font').trigger('click')
const fontMenu = view.get('[role="menu"][aria-label="글꼴"]')
expect(fontMenu.findAll('[role="menuitemradio"]').map((item) => item.find('.menu__label').text())).toEqual([
'맑은 고딕',
'굴림',
'돋움',
'바탕',
'Arial',
'Verdana',
'Georgia',
'Times New Roman',
'Courier New',
])
await fontMenu.findAll('[role="menuitemradio"]')[1]?.trigger('click')
expect(textStyles(view)[0]?.fontFamily).toBe(`Gulim, '굴림', sans-serif`)
expect(tool(view, 'font').attributes('aria-label')).toBe('글꼴: 굴림')
expect(view.find('[role="menu"]').exists()).toBe(false)
await tool(view, 'size').trigger('click')
const sizeMenu = view.get('[role="menu"][aria-label="글자 크기"]')
const eighteen = sizeMenu.findAll('[role="menuitemradio"]').find((item) => item.find('.menu__label').text() === '18')
await eighteen?.trigger('click')
expect(textStyles(view)[0]?.fontSize).toBe('18px')
expect(tool(view, 'size').attributes('aria-label')).toBe('글자 크기: 18')
await tool(view, 'font').trigger('click')
await view.get('[role="menu"]').findAll('[role="menuitemradio"]')[0]?.trigger('click')
expect(textStyles(view)[0]?.fontFamily).toBeNull()
})
it('글자 색 — 8색 중 고르면 적용하고, 고른 색 이름이 단추와 팝오버에 보인다', async () => {
const view = mountEditor()
select(view, 1, 6)
await tool(view, 'textColor').trigger('click')
const picker = view.get('[role="dialog"][aria-label="글자 색"]')
expect(picker.findAll('.swatch')).toHaveLength(8)
await picker.get('[aria-label="빨강"]').trigger('click')
expect(textStyles(view)[0]?.color).toBe('#B42318')
expect(tool(view, 'textColor').attributes('aria-label')).toBe('글자 색: 빨강')
await tool(view, 'textColor').trigger('click')
expect(view.get('[role="dialog"] [aria-label="빨강"]').attributes('aria-pressed')).toBe('true')
expect(view.get('.color-picker__current').text()).toBe('빨강')
})
it('링크 — Ctrl+K 로 열고, http · https · mailto 가 아니면 넣지 않는다. 주소만 쓰면 주소가 글자가 된다', async () => {
const view = mountEditor('<p>안내: </p>')
const editor = editorOf(view.element)
editor.commands.focus('end')
await view.get('.ProseMirror').trigger('keydown', { key: 'k', ctrlKey: true })
const dialog = view.get('[role="dialog"][aria-label="링크 넣기"]')
expect(dialog.text()).not.toContain('링크 빼기')
await dialog.get('#compose-link-url').setValue('javascript:alert(1)')
await dialog.get('#compose-link-url').trigger('keydown', { key: 'Enter' })
expect(dialog.get('[role="alert"]').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
expect(editor.getHTML()).not.toContain('<a ')
await dialog.get('#compose-link-url').setValue('wiki.arasystem.it.kr')
await dialog.get('#compose-link-url').trigger('keydown', { key: 'Enter' })
expect(view.find('[role="dialog"]').exists()).toBe(false)
expect(editor.getHTML()).toContain('<a target="_blank" rel="noopener noreferrer nofollow" href="https://wiki.arasystem.it.kr">wiki.arasystem.it.kr</a>')
})
it('링크 고치기 — 링크 안에서 열면 주소 · 글자가 채워지고 "링크 빼기"로 뺄 수 있다', async () => {
const view = mountEditor('<p>진행 상황은 <a href="https://wiki.example.kr/a">안내 페이지</a>에서</p>')
const editor = editorOf(view.element)
editor.commands.setTextSelection(10)
await tool(view, 'link').trigger('click')
const dialog = view.get('[role="dialog"][aria-label="링크 고치기"]')
expect((dialog.get('#compose-link-url').element as HTMLInputElement).value).toBe('https://wiki.example.kr/a')
expect((dialog.get('#compose-link-text').element as HTMLInputElement).value).toBe('안내 페이지')
const remove = dialog.findAll('button').find((button) => button.text() === '링크 빼기')
await remove?.trigger('click')
expect(editor.getHTML()).toBe('<p>진행 상황은 안내 페이지에서</p>')
})
it('더보기 — 늘 있는 서식과 단축키, 켜진 것은 ✓. Esc 로 닫으면 연 단추로 돌아간다(키보드로 열었을 때)', async () => {
const view = mountEditor()
;(tool(view, 'more').element as HTMLElement).focus()
await tool(view, 'more').trigger('click')
const menu = view.get('[role="menu"][aria-label="서식 더보기"]')
const labels = menu.findAll('.menu__label').map((label) => label.text())
expect(labels).toEqual(['취소선', '왼쪽 정렬', '가운데 정렬', '오른쪽 정렬', '내어쓰기', '들여쓰기', '인용', '표 넣기', '가로줄', '서식 지우기'])
expect(menu.findAll('.menu__hint').map((hint) => hint.text())).toContain('Ctrl+Shift+S')
expect(menu.get('[role="menuitemradio"][aria-checked="true"] .menu__label').text()).toBe('왼쪽 정렬')
expect(tool(view, 'more').attributes('aria-expanded')).toBe('true')
await menu.trigger('keydown', { key: 'Escape' })
await nextTick()
expect(view.find('[role="menu"]').exists()).toBe(false)
expect(document.activeElement?.getAttribute('data-tool')).toBe('more')
})
it('마우스로 열면 메뉴 자체에 초점(고른 항목에 키보드 고리가 뜨지 않게) — 아래 화살표로 항목에 들어간다. 키보드로 열면 켜진 항목에', async () => {
const view = mountEditor()
editorOf(view.element).commands.focus()
await tool(view, 'font').trigger('click')
await nextTick()
const menu = view.get('[role="menu"][aria-label="글꼴"]')
expect(document.activeElement).toBe(menu.element)
await menu.trigger('keydown', { key: 'ArrowDown' })
expect(document.activeElement?.textContent).toContain('맑은 고딕')
await menu.trigger('keydown', { key: 'Escape' })
;(tool(view, 'size').element as HTMLElement).focus()
await tool(view, 'size').trigger('click')
await nextTick()
expect(document.activeElement?.getAttribute('aria-checked')).toBe('true')
expect(document.activeElement?.textContent).toContain('14')
})
it('연 단추로 다시 눌러 닫으면 초점이 사라지지 않는다 — 마우스로 열었으면 본문, 키보드로 열었으면 그 단추로', async () => {
const view = mountEditor()
editorOf(view.element).commands.focus()
await nextFrame()
// 마우스 — 단추는 초점을 받지 않고(mousedown 막음) 메뉴 자체에 초점이 간다
await tool(view, 'font').trigger('click')
await nextTick()
expect(document.activeElement?.getAttribute('role')).toBe('menu')
await tool(view, 'font').trigger('click')
await nextTick()
expect(view.find('[role="menu"]').exists()).toBe(false)
expect(document.activeElement?.id).toBe('compose-body')
// 키보드 — 단추에 초점이 있는 채로 열었다
;(tool(view, 'size').element as HTMLElement).focus()
await tool(view, 'size').trigger('click')
await nextTick()
await tool(view, 'size').trigger('click')
await nextTick()
expect(view.find('[role="menu"]').exists()).toBe(false)
expect(document.activeElement?.getAttribute('data-tool')).toBe('size')
})
it('색 견본은 한 줄에 4개 — 아래 화살표는 다음 줄 같은 칸으로', async () => {
const view = mountEditor()
;(tool(view, 'textColor').element as HTMLElement).focus()
await tool(view, 'textColor').trigger('click')
await nextTick()
const picker = view.get('[role="dialog"][aria-label="글자 색"]')
expect(picker.findAll('.color-picker__row').map((row) => row.findAll('.swatch').length)).toEqual([4, 4])
// 키보드로 열면 고른 색(기본 — 첫 칸)에 초점
expect(document.activeElement?.getAttribute('aria-label')).toBe('기본')
await picker.trigger('keydown', { key: 'ArrowDown' })
expect(document.activeElement?.getAttribute('aria-label')).toBe('초록')
})
it('더보기 › 표 넣기 — 격자에서 고른 크기의 표를 넣는다 (기본 3행 × 4열)', async () => {
const view = mountEditor('<p>표</p>')
editorOf(view.element).commands.focus('end')
await tool(view, 'more').trigger('click')
const table = view.findAll('[role="menuitem"]').find((item) => item.text().includes('표 넣기'))
await table?.trigger('click')
const picker = view.get('[role="dialog"][aria-label="표 넣기"]')
expect(picker.findAll('.table-picker__cell')).toHaveLength(48)
expect(picker.findAll('.table-picker__row').map((row) => row.findAll('.table-picker__cell').length)).toEqual(
Array.from({ length: 6 }, () => 8),
)
expect(picker.get('[aria-live="polite"]').text()).toBe('3행 × 4열')
await picker.get('[data-cell="2-3"]').trigger('mouseenter')
expect(picker.get('[aria-live="polite"]').text()).toBe('2행 × 3열')
await picker.get('[data-cell="2-3"]').trigger('click')
const html = editorOf(view.element).getHTML()
expect(html.match(/<tr>/g)).toHaveLength(2)
expect(html.match(/<td/g)).toHaveLength(6)
})
it('좁은 폭 — 실제 폭을 재어 실행 취소 → 글꼴 → 색 순서로 더보기에 옮기고, 더보기 맨 위에 툴바 순서대로 둔다', async () => {
vi.stubGlobal('ResizeObserver', FakeResizeObserver)
const widths: Record<string, number> = { history: 93, font: 177, marks: 135, color: 93, list: 93, link: 51 }
vi.spyOn(HTMLElement.prototype, 'getBoundingClientRect').mockImplementation(function (this: HTMLElement) {
const width = this.dataset.group ? (widths[this.dataset.group] ?? 0) : this.dataset.tool === 'more' ? 40 : 0
return { width, height: 44, top: 0, left: 0, right: width, bottom: 44, x: 0, y: 0, toJSON: () => ({}) }
})
const view = mountEditor()
const toolbar = view.get('[role="toolbar"]').element as HTMLElement
Object.defineProperty(toolbar, 'clientWidth', { configurable: true, value: 350 })
FakeResizeObserver.notify()
await nextTick()
const shown = (group: string) => (view.get(`[data-group="${group}"]`).element as HTMLElement).style.display !== 'none'
expect(['history', 'font', 'marks', 'color', 'list', 'link'].filter(shown)).toEqual(['marks', 'list', 'link'])
await tool(view, 'more').trigger('click')
const labels = view.get('[role="menu"]').findAll('.menu__label').map((label) => label.text())
expect(labels.slice(0, 7)).toEqual(['실행 취소', '다시 실행', '글꼴', '글자 크기', '글자 색', '배경 색', '취소선'])
// 다시 넓어지면 돌아온다 (숨은 묶음은 마지막으로 잰 폭을 쓴다)
Object.defineProperty(toolbar, 'clientWidth', { configurable: true, value: 800 })
FakeResizeObserver.notify()
await nextTick()
expect(['history', 'font', 'marks', 'color', 'list', 'link'].filter(shown)).toHaveLength(6)
})
})
@@ -0,0 +1,726 @@
<script setup lang="ts">
// 본문 서식 툴바 (디자인 원본 Compose · ComposeEditor — README "본문 서식 편집기")
// 한 줄 · 높이 52: 실행 취소·다시 실행 | 글꼴·글자 크기 | 굵게·기울임·밑줄 | 글자 색·배경 색 | 글머리·번호 목록 | 링크 | 더보기
// - role="toolbar": Tab 한 번에 들어와 화살표·Home·End 로 옮긴다(로빙 tabindex). Esc → 본문, 본문에서 Alt+F10 → 툴바.
// - 키보드로 누른 단추는 초점을 툴바에 두고, 마우스로 누른 단추와 메뉴·팝오버에서 고른 값은 본문으로 돌아간다.
// - 좁은 폭: 툴바의 실제 폭을 재어(ResizeObserver) 정해진 순서로 묶음째 더보기에 옮긴다 (useToolbarCollapse.ts).
// - 팝오버는 연 단추 아래에 붙는다. 묶음이 더보기로 갔으면 더보기 단추 아래 — Teleport 로 자리를 옮긴다.
import type { ChainedCommands, Editor } from '@tiptap/core'
import { computed, nextTick, ref, watch } 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 EditorMenu from '@/components/mail/editor/EditorMenu.vue'
import EditorTablePicker from '@/components/mail/editor/EditorTablePicker.vue'
import EditorToolButton from '@/components/mail/editor/EditorToolButton.vue'
import { applyLink, readLinkTarget, removeLink, type LinkTarget } from '@/components/mail/editor/editor-links'
import type { LinkFormValue, TableSize } from '@/components/mail/editor/editor-menu'
import { readToolbarStatus } from '@/components/mail/editor/editor-state'
import {
MORE_COMMANDS,
fontMenuItems,
iconToolGroups,
isMoreSubmenu,
moreMenuItems,
sizeMenuItems,
} from '@/components/mail/editor/toolbar-menus'
import { useToolbarCollapse } from '@/components/mail/editor/useToolbarCollapse'
import {
EDITOR_BACKGROUND_COLORS,
EDITOR_FONTS,
EDITOR_SHORTCUTS,
EDITOR_TEXT_COLORS,
MAIL_BODY_FONT_SIZE_PX,
MAIL_EDITOR_BODY_ID,
type EditorColor,
} from '@/shared/constants/mail-editor'
import type { ToolbarGroup } from '@/shared/utils/editor-toolbar-layout'
interface Props {
editor: Editor
/** 편집기 상태가 바뀔 때마다 늘어나는 값 — 툴바 표시를 다시 읽는다 */
revision: number
}
const props = defineProps<Props>()
/** 툴바 단추 — 툴바 순서 */
type ToolId =
| 'undo'
| 'redo'
| 'font'
| 'size'
| 'bold'
| 'italic'
| 'underline'
| 'textColor'
| 'backgroundColor'
| 'bulletList'
| 'orderedList'
| 'link'
| 'more'
/** 단추가 속한 묶음 — 더보기는 묶음 밖(늘 남는다) */
const TOOLS: readonly { id: ToolId; group: ToolbarGroup | null }[] = [
{ id: 'undo', group: 'history' },
{ id: 'redo', group: 'history' },
{ id: 'font', group: 'font' },
{ id: 'size', group: 'font' },
{ id: 'bold', group: 'marks' },
{ id: 'italic', group: 'marks' },
{ id: 'underline', group: 'marks' },
{ id: 'textColor', group: 'color' },
{ id: 'backgroundColor', group: 'color' },
{ id: 'bulletList', group: 'list' },
{ id: 'orderedList', group: 'list' },
{ id: 'link', group: 'link' },
{ id: 'more', group: null },
]
type PopupId = 'font' | 'size' | 'textColor' | 'backgroundColor' | 'link' | 'table' | 'more'
/** 팝오버를 닫을 때 초점이 돌아갈 곳 — 키보드로 열었으면 연 단추, 아니면 본문 */
type ReturnFocus = 'toolbar' | 'editor'
interface OpenPopup {
id: PopupId
returnFocus: ReturnFocus
/** 키보드로 열었다 — 처음부터 항목에 초점. 마우스로 열었으면 팝오버 자체에 (고른 항목에 고리가 뜨지 않게) */
focusItems: boolean
}
/** 팝오버가 원래 붙는 단추의 묶음 — 그 묶음이 더보기로 갔으면 더보기 단추에 붙는다 */
const POPUP_GROUP: Partial<Record<PopupId, ToolbarGroup>> = {
font: 'font',
size: 'font',
textColor: 'color',
backgroundColor: 'color',
link: 'link',
}
const rootRef = ref<HTMLElement | null>(null)
/** 좁은 폭에서 더보기로 옮긴 묶음 — 툴바의 실제 폭을 잰다 */
const { hidden } = useToolbarCollapse(rootRef)
const activeTool = ref<ToolId>('undo')
const popup = ref<OpenPopup | null>(null)
/** 링크 팝오버를 연 때의 대상 범위 */
const linkTarget = ref<LinkTarget | null>(null)
const status = computed(() => {
// 편집기 상태는 반응형이 아니다 — revision 이 바뀔 때마다 다시 읽는다
void props.revision
return readToolbarStatus(props.editor)
})
const iconTools = computed(() => iconToolGroups(status.value))
const fontItems = computed(() => fontMenuItems(status.value.font))
const sizeItems = computed(() => sizeMenuItems(status.value.size))
const moreItems = computed(() => moreMenuItems(status.value, hidden.value))
const linkEditing = computed(() => linkTarget.value?.editing ?? false)
function isGroupShown(group: ToolbarGroup | null): boolean {
return group === null || !hidden.value.has(group)
}
function isToolDisabled(id: ToolId): boolean {
if (id === 'undo') return !status.value.canUndo
if (id === 'redo') return !status.value.canRedo
return false
}
const focusableTools = computed(() =>
TOOLS.filter((tool) => isGroupShown(tool.group) && !isToolDisabled(tool.id)).map((tool) => tool.id),
)
/** Tab 으로 들어올 단추 — 마지막으로 머문 단추, 그 단추가 숨었거나 비활성이면 첫 단추 */
const rovingTool = computed<ToolId>(() =>
focusableTools.value.includes(activeTool.value) ? activeTool.value : (focusableTools.value[0] ?? 'more'),
)
function anchorOf(id: PopupId): ToolId {
const group = POPUP_GROUP[id]
if (!group || hidden.value.has(group)) return 'more'
return id as ToolId
}
function toolElement(id: ToolId): HTMLElement | null {
return rootRef.value?.querySelector<HTMLElement>(`[data-tool="${id}"]`) ?? null
}
const popupAnchor = computed<ToolId | null>(() => (popup.value ? anchorOf(popup.value.id) : null))
const popupTrigger = computed(() => (popupAnchor.value ? toolElement(popupAnchor.value) : null))
const popupTarget = computed(() =>
popupAnchor.value
? (rootRef.value?.querySelector<HTMLElement>(`[data-popup-slot="${popupAnchor.value}"]`) ?? null)
: null,
)
/** 툴바 오른쪽 끝(링크 · 더보기)에 붙는 팝오버는 오른쪽을 맞춘다 */
const popupAlign = computed(() =>
popupAnchor.value === 'more' || popupAnchor.value === 'link' ? 'end' : 'start',
)
function isExpanded(id: ToolId): boolean {
return popupAnchor.value === id
}
/** 초점이 툴바 단추에 있는가 (팝오버 안은 빼고) — 키보드로 쓰는 중 */
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()
}
/**
* 본문으로 초점을 바로 옮긴다 (커서 · 선택은 그대로).
* TipTap 의 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 runAndReturn(command: (chain: ChainedCommands) => ChainedCommands) {
popup.value = null
command(props.editor.chain()).run()
focusEditor()
}
function openPopup(id: PopupId, returnFocus: ReturnFocus, 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 과 같은 규칙)
closePopup(id, true)
return
}
// 마우스로 누르면 초점이 옮겨 가지 않는다(mousedown 을 막았다) — 초점이 툴바에 있으면 키보드로 연 것
const byKeyboard = isFocusOnToolbar()
openPopup(id, byKeyboard ? 'toolbar' : 'editor', byKeyboard)
}
function closePopup(id: PopupId, restoreFocus: boolean) {
const open = popup.value
// 이미 다른 팝오버로 바뀌었으면(더보기 → 글꼴) 늦게 온 닫기 신호는 무시한다
if (open?.id !== id) return
const anchor = anchorOf(id)
popup.value = null
if (!restoreFocus) return
// 링크는 Esc · 취소 모두 본문으로 (디자인 README "링크")
if (id === 'link' || open.returnFocus === 'editor') {
focusEditor()
return
}
void nextTick(() => focusTool(anchor))
}
// ── 값 고르기 ────────────────────────────────────────────
function onFontSelect(id: string) {
const font = EDITOR_FONTS.find((item) => item.id === id)
if (!font) return
// 기본 글꼴은 지정을 지운다 — 감싸는 div 의 맑은 고딕을 따른다
runAndReturn((chain) => (font.isBase ? chain.unsetFontFamily() : chain.setFontFamily(font.family)))
}
function onSizeSelect(id: string) {
const size = Number(id)
if (!Number.isFinite(size)) return
runAndReturn((chain) =>
size === MAIL_BODY_FONT_SIZE_PX ? chain.unsetFontSize() : chain.setFontSize(`${size}px`),
)
}
function onTextColorPick(color: EditorColor) {
runAndReturn((chain) => (color.value === null ? chain.unsetColor() : chain.setColor(color.value)))
}
function onBackgroundColorPick(color: EditorColor) {
runAndReturn((chain) =>
color.value === null ? chain.unsetBackgroundColor() : chain.setBackgroundColor(color.value),
)
}
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 onTablePick(size: TableSize) {
runAndReturn((chain) => chain.insertTable({ rows: size.rows, cols: size.cols, withHeaderRow: false }))
}
function onMoreSelect(id: string, viaKeyboard: boolean) {
if (isMoreSubmenu(id)) {
// 한 단계 더 — 같은 자리(더보기 단추 아래)에 연다. 닫으면 더보기를 연 쪽으로 돌아간다
openPopup(id, popup.value?.returnFocus ?? 'editor', viaKeyboard)
return
}
const command = MORE_COMMANDS[id]
if (command) runAndReturn(command)
}
// ── 키보드 ───────────────────────────────────────────────
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
const moves: Record<string, number> = {
ArrowRight: current === last ? 0 : current + 1,
ArrowLeft: current === 0 ? last : current - 1,
Home: 0,
End: last,
}
if (event.key === 'Escape') {
event.preventDefault()
focusEditor()
return
}
if (event.key === 'ArrowDown' && target.hasAttribute('aria-haspopup')) {
// 메뉴 단추 — 아래 화살표로도 연다
event.preventDefault()
target.click()
return
}
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 && TOOLS.some((item) => item.id === tool)) activeTool.value = tool as ToolId
}
// 묶음이 옮겨 가면 그 묶음에 붙어 있던 팝오버는 닫는다 (더보기 · 표 넣기는 그대로)
watch(hidden, () => {
const open = popup.value
if (open && POPUP_GROUP[open.id]) popup.value = null
})
defineExpose({
/** 본문의 Alt+F10 — 툴바로 */
focusToolbar: () => focusTool(rovingTool.value),
/** 본문의 Ctrl+K — 링크 팝오버 */
openLink: () => openPopup('link', 'editor', true),
})
</script>
<template>
<div
ref="rootRef"
class="editor-toolbar"
role="toolbar"
aria-label="본문 서식"
:aria-controls="MAIL_EDITOR_BODY_ID"
@keydown="onKeydown"
@focusin="onFocusIn"
>
<div
v-show="isGroupShown('history')"
class="editor-toolbar__group"
data-group="history"
>
<EditorToolButton
v-for="item in iconTools.history"
:key="item.id"
:tool="item.id"
:label="item.label"
:shortcut="item.shortcut"
:pressed="item.pressed"
:disabled="item.disabled"
:focusable="rovingTool === item.id"
@press="runTool(item.run)"
>
<AppIcon :name="item.icon" />
</EditorToolButton>
<span
class="editor-toolbar__separator"
aria-hidden="true"
/>
</div>
<div
v-show="isGroupShown('font')"
class="editor-toolbar__group"
data-group="font"
>
<div class="editor-toolbar__anchor">
<EditorToolButton
tool="font"
variant="font"
:label="`글꼴: ${status.font.label}`"
title="글꼴"
haspopup="menu"
:expanded="isExpanded('font')"
:focusable="rovingTool === 'font'"
@press="togglePopup('font')"
>
<span class="editor-toolbar__value">{{ status.font.label }}</span>
<AppIcon
name="chevron-down"
:size="12"
:stroke-width="2.25"
/>
</EditorToolButton>
<span
class="editor-toolbar__slot"
data-popup-slot="font"
/>
</div>
<div class="editor-toolbar__anchor">
<EditorToolButton
tool="size"
variant="size"
:label="`글자 크기: ${status.size.label}`"
title="글자 크기"
haspopup="menu"
:expanded="isExpanded('size')"
:focusable="rovingTool === 'size'"
@press="togglePopup('size')"
>
<span class="editor-toolbar__value">{{ status.size.label }}</span>
<AppIcon
name="chevron-down"
:size="12"
:stroke-width="2.25"
/>
</EditorToolButton>
<span
class="editor-toolbar__slot"
data-popup-slot="size"
/>
</div>
<span
class="editor-toolbar__separator"
aria-hidden="true"
/>
</div>
<div
class="editor-toolbar__group"
data-group="marks"
>
<EditorToolButton
v-for="item in iconTools.marks"
:key="item.id"
:tool="item.id"
:label="item.label"
:shortcut="item.shortcut"
:pressed="item.pressed"
:disabled="item.disabled"
:focusable="rovingTool === item.id"
@press="runTool(item.run)"
>
<AppIcon :name="item.icon" />
</EditorToolButton>
<span
class="editor-toolbar__separator"
aria-hidden="true"
/>
</div>
<div
v-show="isGroupShown('color')"
class="editor-toolbar__group"
data-group="color"
>
<div class="editor-toolbar__anchor">
<EditorToolButton
tool="textColor"
:label="`글자 색: ${status.textColor.name}`"
title="글자 색"
haspopup="dialog"
:expanded="isExpanded('textColor')"
:focusable="rovingTool === 'textColor'"
@press="togglePopup('textColor')"
>
<EditorColorGlyph
kind="text"
:color="status.textColor.value"
/>
</EditorToolButton>
<span
class="editor-toolbar__slot"
data-popup-slot="textColor"
/>
</div>
<div class="editor-toolbar__anchor">
<EditorToolButton
tool="backgroundColor"
:label="`배경 색: ${status.backgroundColor.name}`"
title="배경 색"
haspopup="dialog"
:expanded="isExpanded('backgroundColor')"
:focusable="rovingTool === 'backgroundColor'"
@press="togglePopup('backgroundColor')"
>
<EditorColorGlyph
kind="background"
:color="status.backgroundColor.value"
/>
</EditorToolButton>
<span
class="editor-toolbar__slot"
data-popup-slot="backgroundColor"
/>
</div>
<span
class="editor-toolbar__separator"
aria-hidden="true"
/>
</div>
<div
v-show="isGroupShown('list')"
class="editor-toolbar__group"
data-group="list"
>
<EditorToolButton
v-for="item in iconTools.list"
:key="item.id"
:tool="item.id"
:label="item.label"
:shortcut="item.shortcut"
:pressed="item.pressed"
:disabled="item.disabled"
:focusable="rovingTool === item.id"
@press="runTool(item.run)"
>
<AppIcon :name="item.icon" />
</EditorToolButton>
<span
class="editor-toolbar__separator"
aria-hidden="true"
/>
</div>
<div
v-show="isGroupShown('link')"
class="editor-toolbar__group"
data-group="link"
>
<div class="editor-toolbar__anchor">
<EditorToolButton
tool="link"
label="링크"
:shortcut="EDITOR_SHORTCUTS.link"
haspopup="dialog"
:expanded="isExpanded('link')"
:focusable="rovingTool === 'link'"
@press="togglePopup('link')"
>
<AppIcon name="link" />
</EditorToolButton>
<span
class="editor-toolbar__slot"
data-popup-slot="link"
/>
</div>
<span
class="editor-toolbar__separator"
aria-hidden="true"
/>
</div>
<div class="editor-toolbar__anchor">
<EditorToolButton
tool="more"
label="서식 더보기"
haspopup="menu"
:expanded="isExpanded('more')"
:focusable="rovingTool === 'more'"
@press="togglePopup('more')"
>
<AppIcon name="more-horizontal" />
</EditorToolButton>
<span
class="editor-toolbar__slot"
data-popup-slot="more"
/>
</div>
<Teleport
v-if="popup && popupTarget"
:to="popupTarget"
>
<EditorMenu
v-if="popup.id === 'font'"
key="font"
label="글꼴"
class="editor-toolbar__menu editor-toolbar__menu--font"
:items="fontItems"
:trigger="popupTrigger"
:align="popupAlign"
:focus-content="!popup.focusItems"
@select="onFontSelect"
@close="(restore) => closePopup('font', restore)"
/>
<EditorMenu
v-else-if="popup.id === 'size'"
key="size"
label="글자 크기"
class="editor-toolbar__menu editor-toolbar__menu--size"
:items="sizeItems"
:trigger="popupTrigger"
:align="popupAlign"
:focus-content="!popup.focusItems"
@select="onSizeSelect"
@close="(restore) => closePopup('size', restore)"
/>
<EditorColorPicker
v-else-if="popup.id === 'textColor'"
key="textColor"
title="글자 색"
:palette="EDITOR_TEXT_COLORS"
:current="status.textColor.color"
:trigger="popupTrigger"
:align="popupAlign"
:focus-content="!popup.focusItems"
@pick="onTextColorPick"
@close="(restore) => closePopup('textColor', restore)"
/>
<EditorColorPicker
v-else-if="popup.id === 'backgroundColor'"
key="backgroundColor"
title="배경 색"
:palette="EDITOR_BACKGROUND_COLORS"
:current="status.backgroundColor.color"
:trigger="popupTrigger"
:align="popupAlign"
:focus-content="!popup.focusItems"
@pick="onBackgroundColorPick"
@close="(restore) => closePopup('backgroundColor', restore)"
/>
<EditorLinkForm
v-else-if="popup.id === 'link'"
key="link"
:editing="linkEditing"
:initial-href="linkTarget?.href ?? ''"
:initial-text="linkTarget?.text ?? ''"
:trigger="popupTrigger"
:align="popupAlign"
@apply="onLinkApply"
@remove="onLinkRemove"
@close="(restore) => closePopup('link', restore)"
/>
<EditorTablePicker
v-else-if="popup.id === 'table'"
key="table"
:trigger="popupTrigger"
:align="popupAlign"
:focus-content="!popup.focusItems"
@pick="onTablePick"
@close="(restore) => closePopup('table', restore)"
/>
<EditorMenu
v-else
key="more"
label="서식 더보기"
class="editor-toolbar__menu editor-toolbar__menu--more"
:items="moreItems"
:trigger="popupTrigger"
:align="popupAlign"
:focus-content="!popup.focusItems"
@select="onMoreSelect"
@close="(restore) => closePopup('more', restore)"
/>
</Teleport>
</div>
</template>
<style scoped>
.editor-toolbar {
position: relative;
z-index: 2;
display: flex;
flex-wrap: nowrap;
align-items: center;
gap: var(--space-2);
/* 높이 52 (위아래 4) · 왼쪽 20 + 단추 안 12 → 첫 아이콘이 콘텐츠 열 32px 선 */
min-height: var(--size-editor-toolbar);
padding: var(--space-4) var(--editor-toolbar-inline);
border-bottom: var(--border-width) solid var(--color-border-subtle);
background: var(--color-surface);
}
.editor-toolbar__group {
flex: 0 0 auto;
display: flex;
align-items: center;
gap: var(--space-2);
}
/* 팝오버가 이 단추 아래에 붙는다 */
.editor-toolbar__anchor {
position: relative;
display: flex;
}
/* 팝오버를 옮겨 놓는 빈 자리 — 크기가 없다 */
.editor-toolbar__slot {
display: contents;
}
.editor-toolbar__separator {
flex: 0 0 auto;
width: var(--border-width);
height: var(--space-20);
margin: 0 var(--space-4);
background: var(--color-border);
}
.editor-toolbar__value {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: left;
}
/* 메뉴 폭 (디자인 원본 Compose) — 글꼴 216 · 글자 크기 132 · 더보기 256 */
.editor-toolbar__menu--font {
width: 13.5rem;
}
.editor-toolbar__menu--size {
width: 8.25rem;
}
.editor-toolbar__menu--more {
width: 16rem;
}
</style>
@@ -0,0 +1,315 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import MailEditor from '@/components/mail/editor/MailEditor.vue'
import { INITIAL_DRAFT_LIMITS } from '@/components/mail/editor/fit-draft'
import { docToText } from '@/components/mail/editor/mail-serialize'
import { MAIL_BODY_MAX_LENGTH, MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import { buildQuoteHtml, textToParagraphHtml } from '@/shared/utils/mail-quote'
type EditorWrapper = VueWrapper<InstanceType<typeof MailEditor>>
/** 텍스트 11만 자 남짓의 원문에 답장하는 초안 — 한 줄 약 110자 × 1,000줄 */
function longQuoteDraft(): string {
const longText = `${'긴 원문 줄입니다. '.repeat(10)}\n`.repeat(1_000)
return buildQuoteHtml([{ label: '제목', value: '긴 메일' }], textToParagraphHtml(longText))
}
/** 문서의 원문 인용 블록 */
function mailQuoteOf(doc: ProseMirrorNode): ProseMirrorNode | null {
let quote: ProseMirrorNode | null = null
doc.forEach((child) => {
if (child.type.name === 'blockquote' && child.attrs.mailQuote) quote = child
})
return quote
}
describe('MailEditor (서식 편집기)', () => {
let wrapper: EditorWrapper | null = null
beforeAll(installProseMirrorDom)
afterEach(() => {
wrapper?.unmount()
wrapper = null
})
function mountEditor(props: Record<string, unknown> = {}): EditorWrapper {
wrapper = mount(MailEditor, { props, attachTo: document.body })
return wrapper
}
it('본문은 id · 이름 · 여러 줄 입력으로 읽히고, 비었으면 안내 문구를 보인다', () => {
const view = mountEditor()
const body = view.get('#compose-body')
expect(body.attributes('role')).toBe('textbox')
expect(body.attributes('aria-label')).toBe('본문')
expect(body.attributes('aria-multiline')).toBe('true')
expect(body.get('p').attributes('data-placeholder')).toBe('내용을 입력하세요')
expect(view.get('[role="toolbar"]').attributes('aria-label')).toBe('본문 서식')
expect(view.emitted('ready')).toHaveLength(1)
})
it('빈 본문이면 텍스트 메일 — 서식 본문(html)을 보내지 않는다 (빈 줄만 있어도)', () => {
const view = mountEditor()
expect(view.vm.getContent()).toEqual({ text: '', html: null })
editorOf(view.element).commands.setContent('<p></p><p> </p><p></p>')
expect(view.vm.getContent()).toEqual({ text: '', html: null })
})
it('보낼 본문 — 서식 본문은 기본 글꼴 div 로 감싸고, 같은 내용의 텍스트를 함께 만든다', () => {
const view = mountEditor()
editorOf(view.element).commands.setContent('<p>안녕하세요.</p><ul><li><p>하나</p></li></ul><p><strong>끝</strong></p>')
const { text, html } = view.vm.getContent()
expect(text).toBe('안녕하세요.\n- 하나\n끝')
expect(html).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>' +
'<ul style="margin: 0; padding-left: 24px"><li><p style="margin: 0">하나</p></li></ul>' +
'<p style="margin: 0"><strong>끝</strong></p>' +
'</div>',
)
})
it('답장 초안은 원문 인용 머리를 알아보고 편집기 안에서 그대로 둔다', () => {
const view = mountEditor({
initialHtml: buildQuoteHtml(
[
{ label: '보낸 사람', value: '이도윤 <doyun.lee@arasystem.it.kr>' },
{ label: '제목', value: '일정' },
],
'<p>원문</p><img src="https://tracker.example/p.gif"><script>alert(1)</script>',
),
fitToLimits: true,
})
const body = view.get('#compose-body')
expect(body.find('blockquote[data-mail-quote]').exists()).toBe(true)
expect(body.find('table[data-quote-header]').exists()).toBe(true)
// 편집기가 모르는 요소는 빠진다 — 이미지 · 스크립트
expect(body.find('img').exists()).toBe(false)
expect(body.html()).not.toContain('alert(1)')
const { text } = view.vm.getContent()
expect(text).toBe(['', '> 원본 메일', '> 보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>', '> 제목: 일정', '>', '> 원문'].join('\n'))
})
it('원문이 한도를 넘으면 인용을 줄이고 인용 끝에 생략 표시를 둔다 — 내 글 몫(5%)을 남긴다', () => {
// 한 줄 약 110자 × 1,000줄 — 텍스트 한도(100,000)를 조금 넘는다 (블록이 많은 경우는 fit-draft.spec 이 본다)
const view = mountEditor({ initialHtml: longQuoteDraft(), fitToLimits: true })
const { text, html } = view.vm.getContent()
expect(text.length).toBeLessThanOrEqual(INITIAL_DRAFT_LIMITS.text)
expect(text.length).toBeGreaterThan(INITIAL_DRAFT_LIMITS.text * 0.95)
expect(html?.length ?? 0).toBeLessThanOrEqual(INITIAL_DRAFT_LIMITS.html)
expect(text.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
expect(text).toContain('> 제목: 긴 메일')
})
describe('보내기 직전의 길이 (텍스트 100,000 · 서식 500,000 — 최종 직렬화 결과로 잰다)', () => {
it('맞춘 초안에 몇 글자 쓰면 그대로 보낸다 — 남겨 둔 자리 안이다', () => {
const view = mountEditor({ initialHtml: longQuoteDraft(), fitToLimits: true })
const editor = editorOf(view.element)
const quoteBefore = mailQuoteOf(editor.state.doc)
editor.commands.insertContentAt(1, '확인했습니다. 첨부한 자료 검토 부탁드립니다.')
const content = view.vm.getContentForSend()
expect(content?.text.startsWith('확인했습니다. 첨부한 자료 검토 부탁드립니다.')).toBe(true)
expect(content?.text.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
// 인용은 그대로 — 처음 맞춘 모양
expect(mailQuoteOf(editor.state.doc)).toBe(quoteBefore)
})
it('남겨 둔 자리보다 길게 쓰면 인용을 더 줄여 맞추고 편집기에도 그 모양을 보인다 — 내 글은 그대로', () => {
const view = mountEditor({ initialHtml: longQuoteDraft(), fitToLimits: true })
const editor = editorOf(view.element)
const mine = '내가 쓴 긴 답장입니다. '.repeat(600)
editor.commands.insertContentAt(1, mine)
expect(docToText(editor.state.doc).length).toBeGreaterThan(MAIL_BODY_MAX_LENGTH)
const content = view.vm.getContentForSend()
expect(content).not.toBeNull()
expect(content?.text.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
expect(content?.html?.length ?? 0).toBeLessThanOrEqual(MAIL_BODY_HTML_MAX_LENGTH)
expect(content?.text.startsWith(mine.trimEnd())).toBe(true)
expect(content?.text.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
// 보낸 것과 화면이 같다 — 편집기의 지금 내용이 보낸 내용이다
expect(view.vm.getContent()).toEqual(content)
// 실행 취소로 되돌릴 수 있다
editor.commands.undo()
expect(docToText(editor.state.doc).length).toBeGreaterThan(MAIL_BODY_MAX_LENGTH)
})
it('인용이 없는데(또는 인용을 다 줄여도) 내 글만으로 넘으면 null — 편집기는 그대로 둔다', () => {
const view = mountEditor()
const editor = editorOf(view.element)
editor.commands.setContent(`<p>${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)}</p>`)
const before = editor.state.doc
expect(view.vm.getContentForSend()).toBeNull()
expect(editor.state.doc).toBe(before)
})
})
it('본문 오류 안내(describedBy)를 주면 본문이 그 안내를 가리키고 잘못된 입력으로 읽힌다', async () => {
const view = mountEditor()
expect(view.get('#compose-body').attributes('aria-invalid')).toBeUndefined()
await view.setProps({ describedBy: 'compose-body-error' })
expect(view.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
expect(view.get('#compose-body').attributes('aria-invalid')).toBe('true')
await view.setProps({ describedBy: null })
expect(view.get('#compose-body').attributes('aria-describedby')).toBeUndefined()
})
it('focus() — 위치를 주지 않으면 커서 자리 그대로 본문으로 (제목에서 Enter)', () => {
const view = mountEditor({ initialHtml: '<p>첫 줄</p><p>둘째 줄</p>' })
const editor = editorOf(view.element)
editor.commands.setTextSelection(8)
view.vm.focus()
expect(document.activeElement?.id).toBe('compose-body')
expect(editor.state.selection.from).toBe(8)
view.vm.focus('start')
expect(editor.state.selection.from).toBe(1)
})
it('본문이 바뀌면 change 를 알린다 (커서만 옮긴 것은 아니다)', () => {
const view = mountEditor({ initialHtml: '<p>글</p>' })
const editor = editorOf(view.element)
editor.commands.setTextSelection(1)
expect(view.emitted('change')).toBeUndefined()
editor.commands.insertContent('새')
expect(view.emitted('change')).toHaveLength(1)
})
it('들여쓰기 단축키는 더 들이거나 내어 쓸 수 없어도 키를 끝낸다 — 맥의 ⌘[ · ⌘] 가 뒤로 · 앞으로 가기로 새지 않게', () => {
const view = mountEditor({ initialHtml: '<p>글</p><p style="margin-left: 320px">최대</p>' })
const editor = editorOf(view.element)
const press = (key: string) => {
const event = new KeyboardEvent('keydown', { key, ctrlKey: true, bubbles: true, cancelable: true })
editor.view.dom.dispatchEvent(event)
return event.defaultPrevented
}
editor.commands.setTextSelection(1)
expect(press('[')).toBe(true)
expect(editor.state.doc.child(0).attrs.indent).toBe(0)
editor.commands.setTextSelection(editor.state.doc.content.size - 1)
expect(press(']')).toBe(true)
expect(editor.state.doc.child(1).attrs.indent).toBe(8)
})
describe('자동 링크 — 쓰는 중에 띄어 쓰면 주소가 링크가 된다', () => {
function typeInto(view: EditorWrapper, typed: string): string {
const editor = editorOf(view.element)
editor.commands.focus('end')
for (const character of typed) editor.commands.insertContent(character)
return editor.getHTML()
}
it.each([
['www.naver.com ', 'https://www.naver.com'],
['naver.com ', 'https://naver.com'],
['ara.kim@arasystem.it.kr ', 'mailto:ara.kim@arasystem.it.kr'],
['https://wiki.arasystem.it.kr/a ', 'https://wiki.arasystem.it.kr/a'],
])('%s → %s', (typed, href) => {
expect(typeInto(mountEditor(), typed)).toContain(`href="${href}"`)
})
it.each(['1.2.3.4 ', 'javascript:alert(1) ', '파일.txt '])('링크가 되지 않는다: %s', (typed) => {
expect(typeInto(mountEditor(), typed)).not.toContain('<a ')
})
})
it('쓴 여러 칸 띄어쓰기 · 줄 앞 공백은 서식 본문에서 nbsp 로 — 텍스트 대체 본문은 그대로', () => {
const view = mountEditor()
const editor = editorOf(view.element)
editor.commands.focus('start')
for (const character of ' 1. 일시 : 10월 27일') editor.commands.insertContent(character)
const { text, html } = view.vm.getContent()
expect(text).toBe(' 1. 일시 : 10월 27일')
expect(html).toContain('<p style="margin: 0">&nbsp; 1. 일시 &nbsp; &nbsp;: 10월 27일</p>')
})
it('클립보드에 그림만 있으면 본문에 넣지 않고 첨부로 넘긴다 — 이름은 "붙여넣은 이미지 N.png"', () => {
const view = mountEditor()
const dom = view.get('.ProseMirror').element
const image = new File(['png'], 'image.png', { type: 'image/png' })
dom.dispatchEvent(pasteEvent({ files: [image] }))
dom.dispatchEvent(pasteEvent({ files: [new File(['jpg'], 'image.jpg', { type: 'image/jpeg' })] }))
const batches = view.emitted<[File[]]>('files') ?? []
expect(batches.map(([files]) => files.map((file) => file.name))).toEqual([
['붙여넣은 이미지 1.png'],
['붙여넣은 이미지 2.jpg'],
])
expect(batches[0]?.[0]?.[0]?.type).toBe('image/png')
expect(view.vm.getContent().text).toBe('')
})
it.each([
['그림뿐인 HTML(<img>)', { html: '<meta charset="utf-8"><img src="https://img.example/a.png">' }],
['그림 주소뿐인 글', { text: 'https://img.example/a.png' }],
['둘 다', { html: '<img src="https://img.example/a.png">', text: 'https://img.example/a.png' }],
])('브라우저의 "이미지 복사"(%s + 그림 파일)는 글이 없는 것으로 보고 첨부로 넘긴다', (_label, data) => {
const view = mountEditor()
view.get('.ProseMirror').element.dispatchEvent(
pasteEvent({ files: [new File(['png'], 'image.png', { type: 'image/png' })], ...data }),
)
expect(view.emitted<[File[]]>('files')?.[0]?.[0]?.map((file) => file.name)).toEqual(['붙여넣은 이미지 1.png'])
expect(view.vm.getContent().text).toBe('')
})
it('글과 그림이 함께 오면(워드·엑셀 복사) 글을 붙여 넣고 첨부로 넘기지 않는다', () => {
const view = mountEditor()
const dom = view.get('.ProseMirror').element
dom.dispatchEvent(
pasteEvent({ files: [new File(['png'], 'image.png', { type: 'image/png' })], text: '표 내용', html: '<p>표 내용</p>' }),
)
expect(view.emitted('files')).toBeUndefined()
expect(view.vm.getContent().text).toBe('표 내용')
})
it('끌어다 놓은 파일은 이름 그대로 첨부로 넘긴다', () => {
const view = mountEditor()
const report = new File(['pdf'], '보고서.pdf', { type: 'application/pdf' })
const drop = fileDragEvent('drop', [report])
view.get('.ProseMirror').element.dispatchEvent(drop)
expect(view.emitted<[File[]]>('files')?.[0]?.[0]).toEqual([report])
// 편집기가 처리했다 — 쓰기 화면은 같은 파일을 다시 올리지 않는다
expect(drop.defaultPrevented).toBe(true)
})
it('끌어다 놓는 동안 본문 위에 덮개를 보인다', async () => {
const view = mountEditor()
expect(view.find('.mail-editor__drop').exists()).toBe(false)
await view.setProps({ dragging: true })
expect(view.get('.mail-editor__drop').text()).toBe('놓으면 첨부 파일로 올립니다')
})
})
@@ -0,0 +1,385 @@
<script setup lang="ts">
// 메일 본문 서식 편집기 — 툴바 + 편집 영역 (디자인 원본 Compose · ComposeEditor, README "본문 서식 편집기")
// TipTap(ProseMirror)은 이 폴더(components/mail/editor/) 안에서만 쓴다 — 쓰기 화면이 지연 로딩한다(ComposePage).
// - 처음 내용(initialHtml)은 편집기 스키마로 옮긴다. 스키마에 없는 요소(이미지·스크립트 등)는 빠지고,
// 서식 값은 서버가 남기는 형식만 받는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
// - 답장·전달 초안은 한도(텍스트 100,000 · 서식 500,000)에서 내 글 몫(5%)을 남기고 원문 인용을 줄인다(fit-draft.ts).
// 맞춘 내용으로 편집기를 만들므로 실행 취소 기록은 맞춘 내용에서 시작한다.
// 보내기 직전에 다시 재어(getContentForSend) 넘으면 인용을 더 줄인다 — 내 글만으로 넘으면 보내지 않는다.
// - 붙여 넣거나 끌어다 놓은 파일은 본문에 넣지 않고 files 로 넘긴다 — 쓰기 화면이 첨부로 올린다.
// - 편집 영역(.compose-field)의 아래 선이 포커스 선이다 (styles/base.css). 첨부 영역의 위 선은 없다.
import {
Editor,
createDocument,
getHTMLFromFragment,
getSchema,
resolveFocusPosition,
type AnyExtension,
type Content,
} from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { markRaw, onBeforeUnmount, onMounted, ref, watch } from 'vue'
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 { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import { DROP_TO_ATTACH_LABEL, MAIL_EDITOR_BODY_ID } from '@/shared/constants/mail-editor'
import { pastedFiles, pastedImageName, renameFile } from '@/shared/utils/editor-files'
interface Props {
/** 처음 내용 (HTML) — 답장·전달의 원문 인용 */
initialHtml?: string
/** 처음 내용을 한도에 맞춘다 (답장·전달 초안) */
fitToLimits?: boolean
/** 파일을 끌고 있다 — 본문 위에 덮개를 보인다 (쓰기 화면 전체에서 판정한다) */
dragging?: boolean
/** 본문 오류 안내의 id — 있으면 본문이 그 안내를 가리키고 잘못된 입력으로 읽힌다 (본문이 한도를 넘었을 때) */
describedBy?: string | null
}
interface Emits {
/** 편집기를 다 만들었다 — 이제 내용을 읽고 커서를 옮길 수 있다 */
(e: 'ready'): void
/** 본문에 붙여 넣거나 끌어다 놓은 파일 — 첨부로 올린다 */
(e: 'files', files: File[]): void
/** 본문 내용이 바뀌었다 (커서만 옮긴 것은 빼고) */
(e: 'change'): void
}
const props = withDefaults(defineProps<Props>(), {
initialHtml: '',
fitToLimits: false,
dragging: false,
describedBy: null,
})
const emit = defineEmits<Emits>()
const bodyRef = ref<HTMLElement | null>(null)
const toolbarRef = ref<InstanceType<typeof EditorToolbar> | null>(null)
/** 편집기 상태가 바뀔 때마다 늘린다 — 툴바가 다시 읽는다 (편집기 객체는 반응형으로 감싸지 않는다) */
const revision = ref<number>(0)
/** 붙여 넣은 그림의 번호 — "붙여넣은 이미지 1.png" */
let pastedImageCount = 0
/** 편집 영역을 화면에 붙였다 — 그 전에는 편집기 화면(view)이 없다 */
let mounted = false
function initialContent(extensions: AnyExtension[]): Content {
if (!props.initialHtml) return ''
const html = normalizeIncomingHtml(props.initialHtml)
if (!props.fitToLimits) return html
const schema = getSchema(extensions)
// 내 글 몫을 남긴다 — 한도에 꽉 맞추면 몇 글자만 써도 보낼 수 없다
return fitDraftDoc(createDocument(html, schema), schema, INITIAL_DRAFT_LIMITS).toJSON()
}
/** 클립보드에 글 없이 파일만 있으면 첨부로 — 클립보드 그림에는 이름을 붙인다 */
function attachPasted(event: ClipboardEvent): boolean {
const files = pastedFiles(event.clipboardData)
if (files.length === 0) return false
const named = files.map((file) => {
const name = pastedImageName(file, pastedImageCount + 1)
if (name === null) return file
pastedImageCount += 1
return renameFile(file, name)
})
emit('files', named)
return true
}
/** 끌어다 놓은 파일은 모두 첨부로 (본문 안에서 글을 옮긴 것은 그대로 둔다) */
function attachDropped(event: DragEvent, moved: boolean): boolean {
if (moved) return false
const files = Array.from(event.dataTransfer?.files ?? [])
if (files.length === 0) return false
emit('files', files)
return true
}
const extensions = createMailEditorExtensions({
openLink: () => toolbarRef.value?.openLink(),
focusToolbar: () => toolbarRef.value?.focusToolbar(),
})
/** 화면에 붙이기 전에 만든다 — 툴바가 첫 그림부터 편집기 상태를 읽는다 */
const editor = markRaw(
new Editor({
element: null,
extensions,
content: initialContent(extensions),
editorProps: {
// 함수로 둔다 — 오류 안내(describedBy)가 바뀌면 다시 읽는다
attributes: () => ({
id: MAIL_EDITOR_BODY_ID,
'aria-label': '본문',
'aria-multiline': 'true',
...(props.describedBy ? { 'aria-describedby': props.describedBy, 'aria-invalid': 'true' } : {}),
}),
handlePaste: (_view, event) => attachPasted(event),
handleDrop: (_view, event, _slice, moved) => attachDropped(event, moved),
transformPastedHTML: normalizeIncomingHtml,
},
onTransaction: () => {
revision.value += 1
},
onUpdate: () => {
emit('change')
},
}),
)
/** 문서의 보낼 모양 — 서식 본문과 같은 내용의 텍스트 */
function contentOf(doc: ProseMirrorNode): MailEditorContent {
const text = docToText(doc)
// 보이는 내용이 없으면 텍스트 메일로 — 빈 문단만 남은 서식 본문을 보내지 않는다
if (text.trim() === '') return { text: '', html: null }
return { text, html: toMailHtml(getHTMLFromFragment(doc.content, editor.schema)) }
}
function contentFits(content: MailEditorContent): boolean {
return fitsLimits({ text: content.text.length, html: content.html?.length ?? 0 })
}
function getContent(): MailEditorContent {
return contentOf(editor.state.doc)
}
/** 줄인 문서로 바꾼다 — 바뀐 블록(원문 인용)만 갈아 끼워 내 글과 커서는 그대로 둔다. 실행 취소로 되돌릴 수 있다 */
function applyDoc(next: ProseMirrorNode) {
const { state } = editor
const tr = state.tr
state.doc.forEach((child, offset, index) => {
const replacement = next.maybeChild(index)
if (!replacement || replacement === child) return
tr.replaceWith(tr.mapping.map(offset), tr.mapping.map(offset + child.nodeSize), replacement)
})
// 원문을 줄였을 뿐이다 — 바뀐 범위의 글을 자동 링크로 바꾸지 않는다
if (tr.docChanged) editor.view.dispatch(tr.setMeta('preventAutolink', true))
}
/**
* 보내기 직전의 본문 — 한도를 최종 직렬화 결과(보낼 text · html)로 잰다.
* 넘으면 원문 인용을 더 줄여(생략 표시) 맞추고 편집기에도 그 모양을 보인다 — 보낸 것과 화면이 같게.
* 원문 인용이 없거나 인용을 다 줄여도 넘으면(내 글만으로 넘는다) null — 편집기는 그대로 둔다.
*/
function getContentForSend(): MailEditorContent | null {
const current = getContent()
if (contentFits(current)) return current
const { doc } = editor.state
const fitted = fitDraftDoc(doc, editor.schema)
if (fitted === doc) return null
const content = contentOf(fitted)
if (!contentFits(content)) return null
applyDoc(fitted)
return content
}
/**
* 본문에 초점을 준다 — 'start' · 'end' 면 커서를 맨 위(답장) · 맨 끝으로, 주지 않으면 커서 자리 그대로.
* TipTap 의 focus 명령은 다음 화면 그리기 때 초점을 옮긴다 — 곧바로 쓰는 첫 글자가 사라지지 않게 바로 옮긴다.
*/
function focus(position?: 'start' | 'end') {
if (!mounted) return
if (position) {
const selection = resolveFocusPosition(editor.state.doc, position)
if (selection) editor.view.dispatch(editor.state.tr.setSelection(selection).scrollIntoView())
}
editor.view.focus()
}
onMounted(() => {
if (bodyRef.value) {
editor.mount(bodyRef.value)
mounted = true
}
// 붙이면서 플러그인(실행 취소 기록 등)이 켜진다 — 툴바가 다시 읽게 한다
revision.value += 1
emit('ready')
})
onBeforeUnmount(() => {
editor.destroy()
})
// 오류 안내가 생기거나 사라지면 본문의 aria 속성을 다시 그린다
watch(
() => props.describedBy,
() => {
if (mounted && !editor.isDestroyed) editor.view.updateState(editor.state)
},
)
defineExpose({ getContent, getContentForSend, focus })
</script>
<template>
<div class="mail-editor">
<EditorToolbar
ref="toolbarRef"
:editor="editor"
:revision="revision"
/>
<div class="mail-editor__frame compose-field">
<div
ref="bodyRef"
class="mail-editor__body"
/>
<div
v-if="dragging"
class="mail-editor__drop"
aria-hidden="true"
>
<AppIcon name="paperclip" />
<span>{{ DROP_TO_ATTACH_LABEL }}</span>
</div>
</div>
</div>
</template>
<style scoped>
.mail-editor {
flex: 1 1 auto;
display: flex;
flex-direction: column;
min-width: 0;
}
/* 편집 영역 — 아래 hairline 이 포커스 선이 된다 (.compose-field) */
.mail-editor__frame {
position: relative;
flex: 1 1 0;
min-height: var(--size-editor-body-min);
display: flex;
flex-direction: column;
border-bottom: var(--border-width) solid var(--color-border-subtle);
}
.mail-editor__body {
flex: 1 1 auto;
display: flex;
flex-direction: column;
overflow-y: auto;
}
/* 끌어다 놓는 동안의 덮개 — 2px 점선(포커스 선은 실선)이라 모양이 다르다 */
.mail-editor__drop {
position: absolute;
inset: var(--space-8);
z-index: 1;
pointer-events: none;
display: flex;
align-items: center;
justify-content: center;
gap: var(--space-8);
border: var(--focus-ring-width) dashed var(--color-brand);
border-radius: var(--radius-md);
background: var(--color-drop-surface);
color: var(--color-brand-strong);
font-weight: 600;
}
/*
* 편집 영역 안 — ProseMirror 가 그리는 DOM 이라 :deep 으로 꾸민다.
* 받는 사람이 보는 모양(mail-serialize.ts 의 MAIL_HTML_STYLES)과 같은 값이다.
*/
.mail-editor__body :deep(.ProseMirror) {
flex: 1 1 auto;
width: 100%;
max-width: var(--compose-body-max-width);
padding: var(--space-16) var(--content-inline) var(--space-28);
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;
}
/* 문단 사이 여백 0 — Enter 한 번이 한 줄 */
.mail-editor__body :deep(.ProseMirror p) {
margin: 0;
}
.mail-editor__body :deep(.ProseMirror ul),
.mail-editor__body :deep(.ProseMirror ol) {
margin: 0;
padding-left: var(--space-24);
}
/* 인용 막대 — 회색 (브랜드색 2px 선은 포커스 표시라 겹치지 않게) */
.mail-editor__body :deep(.ProseMirror blockquote) {
margin: 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);
}
/* 원문 인용 — 내 글과 24 띄운다 */
.mail-editor__body :deep(.ProseMirror blockquote[data-mail-quote]) {
margin-top: var(--space-24);
}
.mail-editor__body :deep(.ProseMirror p[data-quote-header]) {
font-size: var(--font-size-label);
}
.mail-editor__body :deep(.ProseMirror table) {
margin: 0;
border-collapse: collapse;
}
.mail-editor__body :deep(.ProseMirror td),
.mail-editor__body :deep(.ProseMirror th) {
min-width: 1em;
padding: var(--space-4) var(--space-8);
border: var(--border-width) solid var(--color-border-strong);
vertical-align: top;
}
/* "원본 메일" 머리 표 — 항목 이름(흐린 글자) · 값, 테두리 없음 */
.mail-editor__body :deep(.ProseMirror table[data-quote-header]) {
margin-bottom: var(--space-12);
font-size: var(--font-size-label);
line-height: 1.6;
}
.mail-editor__body :deep(.ProseMirror table[data-quote-header] td) {
padding: 0;
border: 0;
}
.mail-editor__body :deep(.ProseMirror table[data-quote-header] td:first-child) {
padding-right: var(--space-12);
color: var(--color-text-muted);
white-space: nowrap;
}
/* 표 칸을 끌어 고른 상태 */
.mail-editor__body :deep(.ProseMirror .selectedCell) {
background: var(--color-brand-subtle);
}
.mail-editor__body :deep(.ProseMirror hr) {
margin: var(--space-12) 0;
border: 0;
border-top: var(--border-width) solid var(--color-border-strong);
}
.mail-editor__body :deep(.ProseMirror hr.ProseMirror-selectednode) {
border-top-color: var(--color-brand);
}
.mail-editor__body :deep(.ProseMirror a) {
color: var(--color-brand);
}
/* 빈 본문 안내 — 커서는 문구 앞에 선다 */
.mail-editor__body :deep(.ProseMirror p.is-editor-empty:first-child::before) {
content: attr(data-placeholder);
float: left;
height: 0;
color: var(--color-text-muted);
pointer-events: none;
}
</style>
@@ -0,0 +1,58 @@
// 링크 넣기 · 고치기 · 빼기 (디자인 README "링크") — 주소 검사는 팝오버가 마쳤다 (shared/utils/link-href.ts)
// 본문으로 초점을 돌리는 일은 부르는 쪽(툴바)이 한다
import { getMarkRange, type Editor } from '@tiptap/core'
import type { LinkFormValue } from '@/components/mail/editor/editor-menu'
/** 팝오버를 열 때 정한 대상 — 팝오버에 초점이 가 있는 동안 선택이 바뀌어도 이 범위에 적용한다 */
export interface LinkTarget {
from: number
to: number
href: string
/** 지금 보이는 글자 — 고른 글자 또는 링크 글자 */
text: string
/** 이미 있는 링크를 고친다 */
editing: boolean
}
/** 커서가 링크 안이면 그 링크 전체, 아니면 고른 범위 */
export function readLinkTarget(editor: Editor): LinkTarget {
const { state } = editor
const { from, to, empty, $from } = state.selection
const linkType = state.schema.marks.link
if (linkType && editor.isActive('link')) {
const range = getMarkRange($from, linkType)
if (range) {
const href: unknown = editor.getAttributes('link').href
return {
from: range.from,
to: range.to,
href: typeof href === 'string' ? href : '',
text: state.doc.textBetween(range.from, range.to),
editing: true,
}
}
}
return { from, to, href: '', text: empty ? '' : state.doc.textBetween(from, to, ' '), editing: false }
}
/**
* 링크를 넣거나 고친다.
* - 글자를 그대로 두면 고른 범위에 링크만 씌운다 (굵게 같은 다른 서식이 남는다)
* - 글자를 바꿨거나 커서 자리에 넣으면 그 글자로 바꿔 넣고, 뒤에 이어 쓰는 글은 링크가 되지 않게 한다
*/
export function applyLink(editor: Editor, target: LinkTarget, value: LinkFormValue): boolean {
const range = { from: target.from, to: target.to }
const chain = editor.chain()
if (target.from !== target.to && value.text === target.text) {
return chain.setTextSelection(range).setLink({ href: value.href }).run()
}
return chain
.insertContentAt(range, { type: 'text', text: value.text, marks: [{ type: 'link', attrs: { href: value.href } }] })
.unsetMark('link')
.run()
}
export function removeLink(editor: Editor, target: LinkTarget): boolean {
return editor.chain().setTextSelection({ from: target.from, to: target.to }).unsetLink().run()
}
@@ -0,0 +1,48 @@
// 툴바 메뉴 · 팝오버가 주고받는 값 — 글꼴 · 글자 크기 · 더보기 메뉴는 같은 항목 모양을 쓴다 (디자인 README "더보기")
import type { IconName } from '@/shared/constants/icons'
export interface EditorMenuEntry {
id: string
/** 구분선 */
separator?: false
role?: 'menuitem' | 'menuitemcheckbox' | 'menuitemradio'
label: string
icon?: IconName
/** 글자 색 · 배경 색 그림 (지금 색이 보인다) */
colorGlyph?: { kind: 'text' | 'background'; color: string | null }
/** 이름 옆 흐린 글자 — "기본" */
badge?: string
/** 오른쪽 흐린 글자 — 단축키 또는 지금 값 */
hint?: string
checked?: boolean
disabled?: boolean
/** 한 단계 더 여는 항목 (›) — 메뉴인지 대화상자인지 */
opens?: 'menu' | 'dialog'
/** 미리보기 모양 (글꼴 · 크기) — EditorMenu 의 class 이름 */
preview?: string
}
export interface EditorMenuSeparator {
id: string
separator: true
}
export type EditorMenuItem = EditorMenuEntry | EditorMenuSeparator
export function menuSeparator(id: string): EditorMenuSeparator {
return { id, separator: true }
}
/** 링크 팝오버가 넘기는 값 */
export interface LinkFormValue {
/** 링크 주소 — 검사를 통과한 값 (프로토콜이 없으면 붙였다) */
href: string
/** 보일 글자 — 비우면 사용자가 쓴 주소 그대로 */
text: string
}
/** 표 넣기에서 고른 크기 */
export interface TableSize {
rows: number
cols: number
}
@@ -0,0 +1,109 @@
import { Editor } from '@tiptap/core'
import { afterEach, describe, expect, it } from 'vitest'
import { colorFromValue, fontFromFamily, readToolbarStatus, sizeFromValue } from '@/components/mail/editor/editor-state'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { EDITOR_BACKGROUND_COLORS, EDITOR_TEXT_COLORS } from '@/shared/constants/mail-editor'
describe('값 → 목록 항목', () => {
it('글꼴 — 따옴표 · 공백이 달라도 같은 글꼴, 지정 없음은 기본(맑은 고딕)', () => {
expect(fontFromFamily(null)?.id).toBe('malgun')
expect(fontFromFamily('Gulim, "굴림", sans-serif')?.id).toBe('gulim')
expect(fontFromFamily("'Times New Roman',serif")?.id).toBe('times')
expect(fontFromFamily('Calibri, sans-serif')).toBeNull()
})
it('글자 크기 — px 만 숫자로', () => {
expect(sizeFromValue('16px')).toBe(16)
expect(sizeFromValue('13.33px')).toBe(13.33)
expect(sizeFromValue('11pt')).toBeNull()
expect(sizeFromValue(null)).toBeNull()
})
it('색 — 대소문자 · rgb() 로 와도 팔레트 색, 지정 없음은 "기본"/"없음"', () => {
expect(colorFromValue('#b42318', EDITOR_TEXT_COLORS)?.name).toBe('빨강')
expect(colorFromValue('rgb(180, 35, 24)', EDITOR_TEXT_COLORS)?.name).toBe('빨강')
expect(colorFromValue(null, EDITOR_TEXT_COLORS)?.name).toBe('기본')
expect(colorFromValue(null, EDITOR_BACKGROUND_COLORS)?.name).toBe('없음')
expect(colorFromValue('#123456', EDITOR_TEXT_COLORS)).toBeNull()
})
})
describe('readToolbarStatus — 툴바에 보일 현재 서식', () => {
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
function create(content: string): Editor {
editor = new Editor({ element: document.createElement('div'), extensions: createMailEditorExtensions(), content })
return editor
}
it('아무 서식이 없으면 기본 글꼴 · 14 · 기본 색 · 없음 · 왼쪽 정렬', () => {
const status = readToolbarStatus(create('<p>글</p>'))
expect(status.font.label).toBe('맑은 고딕')
expect(status.size.label).toBe('14')
expect(status.textColor.name).toBe('기본')
expect(status.backgroundColor.name).toBe('없음')
expect(status.align).toBe('left')
expect(status.canUndo).toBe(false)
})
it('고른 글자의 서식이 섞이면 "–"', () => {
const view = create('<p><span style="font-size: 18px">큰</span>보통</p>')
view.commands.setTextSelection({ from: 1, to: 5 })
const status = readToolbarStatus(view)
expect(status.size).toEqual({ px: null, label: '–', mixed: true })
expect(status.font.mixed).toBe(false)
})
it('켜진 서식 · 목록 밖 글꼴(첫 이름) · 팔레트 밖 색(다른 색) · 정렬', () => {
const view = create(
'<p style="text-align: center"><strong><span style="font-family: Helvetica, sans-serif; color: #123456">글</span></strong></p>',
)
view.commands.setTextSelection(2)
view.commands.insertContent('자')
const status = readToolbarStatus(view)
expect(status.bold).toBe(true)
expect(status.font.label).toBe('Helvetica')
expect(status.textColor).toMatchObject({ color: null, value: '#123456', name: '다른 색' })
expect(status.align).toBe('center')
expect(status.canUndo).toBe(true)
})
it('보내는 HTML 형식 밖의 글꼴(Calibri)은 읽지 않는다 — 기본 글꼴로 보인다', () => {
const view = create('<p><span style="font-family: Calibri, sans-serif">글</span></p>')
view.commands.setTextSelection(2)
expect(readToolbarStatus(view).font.label).toBe('맑은 고딕')
})
it('툴바 그림에 쓰는 색은 형식을 확인한 값만 — 저장된 내용(JSON)으로 들어온 이상한 값은 그리지 않는다', () => {
const view = create('<p>글</p>')
view.commands.setContent({
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: '글', marks: [{ type: 'textStyle', attrs: { color: 'url(#paint)' } }] }],
},
],
})
view.commands.setTextSelection(2)
const status = readToolbarStatus(view)
expect(status.textColor.value).toBeNull()
expect(status.textColor.name).toBe('다른 색')
// 편집 영역도 그 값을 그리지 않는다 (보내는 HTML 과 같다)
expect(view.getHTML()).toBe('<p><span>글</span></p>')
})
})
@@ -0,0 +1,195 @@
// 툴바에 보일 현재 서식 — 고른 글자의 글꼴·크기·색 (섞였으면 "–"), 켜진 서식, 실행 취소 가능 여부
import type { Editor } from '@tiptap/core'
import type { Mark } from '@tiptap/pm/model'
import type { EditorState } from '@tiptap/pm/state'
import {
EDITOR_BACKGROUND_COLORS,
EDITOR_FONTS,
EDITOR_MIXED_VALUE,
EDITOR_OTHER_COLOR_NAME,
EDITOR_TEXT_COLORS,
MAIL_BODY_FONT_SIZE_PX,
type EditorColor,
type EditorFont,
} from '@/shared/constants/mail-editor'
import { isOutgoingStyleValue } from '@/shared/utils/outgoing-html-rules'
export type TextStyleAttribute = 'fontFamily' | 'fontSize' | 'color' | 'backgroundColor'
function styleValue(marks: readonly Mark[], attribute: TextStyleAttribute): string | null {
const mark = marks.find((item) => item.type.name === 'textStyle')
const value: unknown = mark?.attrs[attribute]
return typeof value === 'string' && value !== '' ? value : null
}
/**
* 고른 글자(커서면 지금 쓸 글자)의 textStyle 값 모음.
* 값이 하나면 그 값(지정 없음은 null), 둘 이상이면 섞였다.
*/
export function selectionStyleValues(state: EditorState, attribute: TextStyleAttribute): Set<string | null> {
const { from, to, empty, $from } = state.selection
if (empty) return new Set([styleValue(state.storedMarks ?? $from.marks(), attribute)])
const values = new Set<string | null>()
state.doc.nodesBetween(from, to, (node) => {
if (node.isText) values.add(styleValue(node.marks, attribute))
return true
})
if (values.size === 0) values.add(styleValue($from.marks(), attribute))
return values
}
/** 글꼴 이름 비교용 — 따옴표·공백·대소문자를 무시한다 */
function normalizeFamily(family: string): string {
return family.replace(/["']/g, '').replace(/\s*,\s*/g, ',').trim().toLowerCase()
}
/** 글꼴 값 → 목록의 글꼴 (지정 없음은 기본 글꼴, 목록에 없으면 null) */
export function fontFromFamily(family: string | null): EditorFont | null {
if (family === null) return EDITOR_FONTS.find((font) => font.isBase) ?? null
const target = normalizeFamily(family)
return EDITOR_FONTS.find((font) => normalizeFamily(font.family) === target) ?? null
}
/** "16px" → 16, 지정 없음·px 가 아닌 값은 null */
export function sizeFromValue(value: string | null): number | null {
const match = value?.trim().match(/^(\d+(?:\.\d+)?)px$/i)
return match?.[1] ? Number.parseFloat(match[1]) : null
}
const RGB_PATTERN = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$/i
/** 색 값 비교용 — rgb(…) 는 #rrggbb 로, 글자는 작은 글자로 (붙여 넣은 글은 rgb 로 오기도 한다) */
function normalizeColor(value: string): string {
const match = value.trim().match(RGB_PATTERN)
if (!match) return value.trim().toLowerCase()
const hex = match
.slice(1, 4)
.map((part) => Math.min(255, Number(part)).toString(16).padStart(2, '0'))
.join('')
return `#${hex}`
}
/** 색 값 → 팔레트의 색 (지정 없음은 "기본"/"없음", 팔레트 밖이면 null) */
export function colorFromValue(value: string | null, palette: readonly EditorColor[]): EditorColor | null {
if (value === null) return palette.find((color) => color.value === null) ?? null
const target = normalizeColor(value)
return palette.find((color) => color.value !== null && normalizeColor(color.value) === target) ?? null
}
// ── 툴바 상태 ────────────────────────────────────────────
export type TextAlignment = 'left' | 'center' | 'right'
export interface FontStatus {
/** 목록의 글꼴 — 섞였거나 목록 밖이면 null */
font: EditorFont | null
label: string
mixed: boolean
}
export interface SizeStatus {
/** 글자 크기(px) — 지정 없음은 기본(14), 섞였거나 px 가 아니면 null */
px: number | null
label: string
mixed: boolean
}
export interface ColorStatus {
/** 팔레트의 색 — 섞였거나 팔레트 밖이면 null */
color: EditorColor | null
/** 그림(툴바 단추의 SVG fill)에 쓸 색 값 — 보내는 HTML 형식(#hex · rgb())인 값만. 지정 없음·섞임·그 밖은 null */
value: string | null
name: string
mixed: boolean
}
export interface ToolbarStatus {
canUndo: boolean
canRedo: boolean
bold: boolean
italic: boolean
underline: boolean
strike: boolean
bulletList: boolean
orderedList: boolean
blockquote: boolean
link: boolean
/** 표 안 — 표 안에 표를 넣지 않는다 */
inTable: boolean
align: TextAlignment
font: FontStatus
size: SizeStatus
textColor: ColorStatus
backgroundColor: ColorStatus
}
/** 하나뿐인 값 — 섞였으면 undefined */
function single(values: Set<string | null>): string | null | undefined {
return values.size === 1 ? [...values][0] : undefined
}
/** 목록 밖 글꼴은 첫 이름을 보인다 ("Calibri, sans-serif" → Calibri) */
function firstFamilyName(family: string): string {
return family.split(',')[0]?.replace(/["']/g, '').trim() || EDITOR_MIXED_VALUE
}
function fontStatus(state: EditorState): FontStatus {
const value = single(selectionStyleValues(state, 'fontFamily'))
if (value === undefined) return { font: null, label: EDITOR_MIXED_VALUE, mixed: true }
const font = fontFromFamily(value)
return { font, label: font?.label ?? firstFamilyName(value ?? ''), mixed: false }
}
/** 크기 표시 — 소수는 한 자리까지 */
function sizeLabel(px: number): string {
return String(Math.round(px * 10) / 10)
}
function sizeStatus(state: EditorState): SizeStatus {
const value = single(selectionStyleValues(state, 'fontSize'))
if (value === undefined) return { px: null, label: EDITOR_MIXED_VALUE, mixed: true }
const px = value === null ? MAIL_BODY_FONT_SIZE_PX : sizeFromValue(value)
return { px, label: px === null ? EDITOR_MIXED_VALUE : sizeLabel(px), mixed: false }
}
function colorStatus(
state: EditorState,
attribute: 'color' | 'backgroundColor',
palette: readonly EditorColor[],
): ColorStatus {
const value = single(selectionStyleValues(state, attribute))
if (value === undefined) return { color: null, value: null, name: EDITOR_MIXED_VALUE, mixed: true }
const color = colorFromValue(value, palette)
const drawable = value !== null && isOutgoingStyleValue('color', value) ? value : null
return { color, value: drawable, name: color?.name ?? EDITOR_OTHER_COLOR_NAME, mixed: false }
}
function alignment(editor: Editor): TextAlignment {
if (editor.isActive({ textAlign: 'center' })) return 'center'
if (editor.isActive({ textAlign: 'right' })) return 'right'
return 'left'
}
/** 지금 편집기 상태로 툴바 표시를 정한다 — 거래(transaction)마다 다시 읽는다 */
export function readToolbarStatus(editor: Editor): ToolbarStatus {
const { state } = editor
return {
canUndo: editor.can().undo(),
canRedo: editor.can().redo(),
bold: editor.isActive('bold'),
italic: editor.isActive('italic'),
underline: editor.isActive('underline'),
strike: editor.isActive('strike'),
bulletList: editor.isActive('bulletList'),
orderedList: editor.isActive('orderedList'),
blockquote: editor.isActive('blockquote'),
link: editor.isActive('link'),
inTable: editor.isActive('table'),
align: alignment(editor),
font: fontStatus(state),
size: sizeStatus(state),
textColor: colorStatus(state, 'color', EDITOR_TEXT_COLORS),
backgroundColor: colorStatus(state, 'backgroundColor', EDITOR_BACKGROUND_COLORS),
}
}
@@ -0,0 +1,130 @@
import { createDocument, getSchema } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { describe, expect, it } from 'vitest'
import {
DRAFT_LIMITS,
DRAFT_WRITING_RESERVE,
INITIAL_DRAFT_LIMITS,
fitDraftDoc,
fitsLimits,
measureDraft,
type DraftLimits,
} from '@/components/mail/editor/fit-draft'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { docToText } from '@/components/mail/editor/mail-serialize'
import { MAIL_BODY_MAX_LENGTH, MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import { buildQuoteHtml, textToParagraphHtml } from '@/shared/utils/mail-quote'
const schema = getSchema(createMailEditorExtensions())
const HEADER = [{ label: '보낸 사람', value: '이도윤 <doyun.lee@arasystem.it.kr>' }]
function draft(bodyHtml: string, mine = ''): ProseMirrorNode {
const html = buildQuoteHtml(HEADER, bodyHtml).replace('<p></p>', `<p>${mine}</p>`)
return createDocument(html, schema)
}
function lines(count: number, text = '원문 줄'): string {
return Array.from({ length: count }, (_, index) => `<p>${text} ${index + 1}</p>`).join('')
}
function quoteTexts(doc: ProseMirrorNode): string[] {
const quote = doc.child(1)
const texts: string[] = []
quote.forEach((child) => texts.push(child.textContent))
return texts
}
describe('한도 — 보내는 한도와 처음 초안의 한도 (계약 2절 "답장·전달 초안의 길이")', () => {
it('보내는 한도는 계약 값, 처음 초안은 내 글 몫(5%)을 뺀 만큼', () => {
expect(DRAFT_LIMITS).toEqual({ text: MAIL_BODY_MAX_LENGTH, html: MAIL_BODY_HTML_MAX_LENGTH })
expect(DRAFT_WRITING_RESERVE).toEqual({ text: 5_000, html: 25_000 })
expect(INITIAL_DRAFT_LIMITS).toEqual({ text: 95_000, html: 475_000 })
})
it('fitsLimits — 텍스트 · 서식 본문 둘 다 한도 안이어야 한다', () => {
expect(fitsLimits({ text: MAIL_BODY_MAX_LENGTH, html: MAIL_BODY_HTML_MAX_LENGTH })).toBe(true)
expect(fitsLimits({ text: MAIL_BODY_MAX_LENGTH + 1, html: 0 })).toBe(false)
expect(fitsLimits({ text: 10, html: MAIL_BODY_HTML_MAX_LENGTH + 1 })).toBe(false)
})
})
describe('fitDraftDoc — 답장·전달 초안 한도 (계약 2절)', () => {
it('한도 안이면 그대로 둔다', () => {
const doc = draft(lines(3))
expect(fitDraftDoc(doc, schema)).toBe(doc)
})
it('넘으면 원문 블록을 앞에서부터 담고 인용 끝에 생략 표시를 둔다 — 머리("원본 메일" · 항목 표)는 남는다', () => {
const doc = draft(lines(200))
const limits: DraftLimits = { text: 600, html: 100_000 }
const fitted = fitDraftDoc(doc, schema, limits)
const size = measureDraft(fitted, schema)
const texts = quoteTexts(fitted)
expect(size.text).toBeLessThanOrEqual(limits.text)
expect(texts[0]).toBe('원본 메일')
expect(texts[1]).toContain('보낸 사람')
expect(texts[2]).toBe('원문 줄 1')
expect(texts.at(-1)).toBe(MAIL_QUOTE_OMITTED_MARK)
// 남는 자리를 다 쓴다 — 한 줄 더 담으면 넘는다
expect(texts.length).toBeGreaterThan(10)
})
it('서식 본문(HTML) 한도도 지킨다', () => {
const doc = draft(lines(500))
const limits: DraftLimits = { text: 1_000_000, html: 6_000 }
const fitted = fitDraftDoc(doc, schema, limits)
expect(measureDraft(fitted, schema).html).toBeLessThanOrEqual(limits.html)
expect(quoteTexts(fitted).at(-1)).toBe(MAIL_QUOTE_OMITTED_MARK)
})
it('블록이 많아도(묶음 여러 개) 묶음으로 재서 맞춘다', () => {
const doc = draft(lines(1_500))
const limits: DraftLimits = { text: 6_000, html: 200_000 }
const fitted = fitDraftDoc(doc, schema, limits)
const size = measureDraft(fitted, schema)
expect(size.text).toBeLessThanOrEqual(limits.text)
expect(size.html).toBeLessThanOrEqual(limits.html)
expect(size.text).toBeGreaterThan(limits.text * 0.9)
})
it('긴 한 문단짜리 원문은 글자 단위로 잘라 담는다 — 이모지 가운데는 자르지 않는다', () => {
const doc = draft(textToParagraphHtml('가😀'.repeat(5_000)))
const limits: DraftLimits = { text: 2_000, html: 100_000 }
const fitted = fitDraftDoc(doc, schema, limits)
const kept = quoteTexts(fitted)[2] ?? ''
expect(measureDraft(fitted, schema).text).toBeLessThanOrEqual(limits.text)
expect(kept.length).toBeGreaterThan(1_000)
expect(kept.endsWith('\uD83D')).toBe(false)
expect(quoteTexts(fitted).at(-1)).toBe(MAIL_QUOTE_OMITTED_MARK)
})
it('내가 쓴 글은 줄이지 않는다 — 원문 인용만 줄인다', () => {
const doc = draft(lines(200), '내가 쓴 첫 줄')
const fitted = fitDraftDoc(doc, schema, { text: 600, html: 100_000 })
expect(fitted.child(0).textContent).toBe('내가 쓴 첫 줄')
})
it('원문 인용이 없는 문서는 그대로 둔다 (넘어도 — 보낼 때 서버가 VAL_001 로 막는다)', () => {
const doc = createDocument(lines(200), schema)
expect(fitDraftDoc(doc, schema, { text: 100, html: 1_000 })).toBe(doc)
})
it('텍스트로 보면 "> " 인용 줄과 생략 표시가 된다', () => {
const fitted = fitDraftDoc(draft(lines(200)), schema, { text: 400, html: 100_000 })
expect(docToText(fitted).endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
})
})
@@ -0,0 +1,174 @@
// 답장·전달 초안을 계약 한도에 맞춘다 — 계약 2절 "답장·전달 초안의 길이"
// 본문 텍스트(body) 100,000자 · 서식 본문(bodyHtml) 500,000자. 넘으면 원문 인용을 줄이고 인용 끝에 생략 표시를 둔다.
// 길이는 실제로 보낼 모양(toMailHtml · docToText)으로 잰다 — 보낼 때 다시 넘지 않게.
// - 처음 초안: 한도에서 내 글 몫(DRAFT_WRITING_RESERVE)을 남기고 맞춘다 — 꽉 채우면 몇 글자만 써도 보낼 수 없다
// - 보내기 직전: 한도 그대로 다시 잰다. 넘으면 인용을 더 줄인다 (MailEditor 의 getContentForSend)
// 아주 긴 원문도 빠르게: 블록을 묶음으로 재고, 넘치는 묶음 안에서만 블록 하나씩, 마지막 글 블록은 글자 단위로 자른다.
import { getHTMLFromFragment } from '@tiptap/core'
import { Fragment, type Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
import { MAIL_BODY_MAX_LENGTH, MAIL_QUOTE_LINE_PREFIX, MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import { blockLines, docToText, toMailHtml, toMailHtmlFragment } from './mail-serialize'
export interface DraftLimits {
text: number
html: number
}
/** 발송 본문 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
export const DRAFT_LIMITS: DraftLimits = { text: MAIL_BODY_MAX_LENGTH, html: MAIL_BODY_HTML_MAX_LENGTH }
/**
* 처음 초안을 맞출 때 내 글 몫으로 남기는 자리 — 한도의 5% (텍스트 5,000 · 서식 25,000).
* 그보다 길게 쓰면 보내기 직전에 인용을 그만큼 더 줄인다.
*/
export const DRAFT_WRITING_RESERVE: DraftLimits = {
text: Math.round(MAIL_BODY_MAX_LENGTH * 0.05),
html: Math.round(MAIL_BODY_HTML_MAX_LENGTH * 0.05),
}
/** 처음 초안의 한도 — 내 글 몫을 뺀 만큼 */
export const INITIAL_DRAFT_LIMITS: DraftLimits = {
text: DRAFT_LIMITS.text - DRAFT_WRITING_RESERVE.text,
html: DRAFT_LIMITS.html - DRAFT_WRITING_RESERVE.html,
}
/** 한 번에 재는 블록 수 — 원문이 문단 수만 개여도 DOM 직렬화 횟수를 줄인다 */
const MEASURE_CHUNK = 64
export interface DraftSize {
text: number
html: number
}
/** 보낼 모양 그대로의 길이 */
export function measureDraft(doc: ProseMirrorNode, schema: Schema): DraftSize {
return {
text: docToText(doc).length,
html: toMailHtml(getHTMLFromFragment(doc.content, schema)).length,
}
}
export function fitsLimits(size: DraftSize, limits: DraftLimits = DRAFT_LIMITS): boolean {
return size.text <= limits.text && size.html <= limits.html
}
/** 인용 안에 둘 때의 텍스트 길이 — 줄마다 "> " 와 줄바꿈 */
function quotedTextLength(blocks: readonly ProseMirrorNode[]): number {
return blocks.reduce(
(sum, block) =>
sum + blockLines(block).reduce((lines, line) => lines + MAIL_QUOTE_LINE_PREFIX.length + line.length + 1, 0),
0,
)
}
function htmlLength(blocks: readonly ProseMirrorNode[], schema: Schema): number {
return toMailHtmlFragment(getHTMLFromFragment(Fragment.fromArray([...blocks]), schema)).length
}
/** 글 블록을 앞에서 size 위치까지 — 이모지(서로게이트 쌍) 가운데는 자르지 않는다 */
function cutTextblock(block: ProseMirrorNode, size: number): ProseMirrorNode {
let end = size
const last = block.textBetween(Math.max(0, end - 1), end)
const code = last.charCodeAt(last.length - 1)
if (last.length > 0 && code >= 0xd800 && code <= 0xdbff) end -= 1
return block.cut(0, end)
}
function children(node: ProseMirrorNode): ProseMirrorNode[] {
const list: ProseMirrorNode[] = []
node.forEach((child) => list.push(child))
return list
}
/** 인용 머리 — 맨 앞의 "원본 메일" 문단과 항목 표 */
function headerCount(quote: ProseMirrorNode): number {
let count = 0
for (const child of children(quote)) {
if (!child.attrs.quoteHeader) break
count += 1
}
return count
}
/**
* 한도를 넘는 초안이면 원문 인용을 줄인 문서를 돌려준다. 넘지 않거나 원문 인용이 없으면 그대로.
* (원문 인용 = 맨 위 단계의 data-mail-quote 인용 — 사용자가 쓴 글은 줄이지 않는다)
*/
export function fitDraftDoc(
doc: ProseMirrorNode,
schema: Schema,
limits: DraftLimits = DRAFT_LIMITS,
): ProseMirrorNode {
if (fitsLimits(measureDraft(doc, schema), limits)) return doc
let quoteIndex = -1
doc.forEach((child, _offset, index) => {
if (child.type.name === 'blockquote' && child.attrs.mailQuote) quoteIndex = index
})
if (quoteIndex < 0) return doc
const quote = doc.child(quoteIndex)
const all = children(quote)
const header = all.slice(0, headerCount(quote))
const content = all.slice(header.length)
const paragraph = schema.nodes.paragraph
if (!paragraph) return doc
const marker = paragraph.create(null, schema.text(MAIL_QUOTE_OMITTED_MARK))
const build = (blocks: readonly ProseMirrorNode[]) =>
doc.copy(
doc.content.replaceChild(quoteIndex, quote.copy(Fragment.fromArray([...header, ...blocks, marker]))),
)
// 머리와 생략 표시만 둔 문서가 쓰는 길이 — 남는 만큼 원문 블록을 담는다
const base = measureDraft(build([]), schema)
let textBudget = limits.text - base.text
let htmlBudget = limits.html - base.html
if (textBudget < 0 || htmlBudget < 0) return build([])
const kept: ProseMirrorNode[] = []
let overflow: ProseMirrorNode | null = null
const tryAdd = (blocks: readonly ProseMirrorNode[]): boolean => {
const text = quotedTextLength(blocks)
const html = htmlLength(blocks, schema)
if (text > textBudget || html > htmlBudget) return false
kept.push(...blocks)
textBudget -= text
htmlBudget -= html
return true
}
chunks: for (let start = 0; start < content.length; start += MEASURE_CHUNK) {
const chunk = content.slice(start, start + MEASURE_CHUNK)
if (tryAdd(chunk)) continue
for (const block of chunk) {
if (!tryAdd([block])) {
overflow = block
break chunks
}
}
}
// 넘친 블록이 글 블록이면 들어가는 만큼 글자 단위로 담는다 (긴 한 문단짜리 원문도 비지 않게)
if (overflow?.isTextblock && overflow.content.size > 0) {
let low = 0
let high = overflow.content.size - 1
while (low < high) {
const middle = Math.ceil((low + high) / 2)
const part = cutTextblock(overflow, middle)
if (quotedTextLength([part]) <= textBudget && htmlLength([part], schema) <= htmlBudget) low = middle
else high = middle - 1
}
if (low > 0) kept.push(cutTextblock(overflow, low))
}
// 묶어서 잰 값은 실제와 몇 글자 어긋날 수 있다 — 전체를 다시 재고 넘치면 끝에서 하나씩 뺀다
let result = build(kept)
while (kept.length > 0 && !fitsLimits(measureDraft(result, schema), limits)) {
kept.pop()
result = build(kept)
}
return result
}
@@ -0,0 +1,28 @@
// 쓰기 화면이 편집기에 바라는 것 — 편집기는 지연 로딩하므로 화면은 이 파일만 안다 (TipTap 을 불러오지 않는다)
import type { InjectionKey } from 'vue'
/** 보낼 본문 — 계약 2절 "발송 본문" · "서식 본문" */
export interface MailEditorContent {
/** 일반 텍스트 (body) — 서식 본문의 대체 본문. 비었으면 '' */
text: string
/** 서식 본문 (bodyHtml) — 기본 글꼴 div 로 감쌌다. 본문이 비었으면 null (텍스트 메일로 보낸다) */
html: string | null
}
export interface MailEditorHandle {
/** 지금 본문 — 보낼 모양 그대로 */
getContent: () => MailEditorContent
/**
* 보내기 직전의 본문 — 한도(텍스트 100,000 · 서식 500,000)를 최종 직렬화 결과로 잰다.
* 넘으면 원문 인용을 더 줄여 맞추고 편집기에도 그 모양을 보인다(생략 표시 유지). 내 글만으로 넘으면 null — 보내지 않는다.
*/
getContentForSend: () => MailEditorContent | null
/** 본문으로 초점을 옮긴다 — 'start' 는 맨 위 첫 줄(답장), 'end' 는 맨 끝, 주지 않으면 커서 자리 그대로 */
focus: (position?: 'start' | 'end') => void
}
/** 편집기 청크를 내려받는다 — 쓰기 화면의 defineAsyncComponent 가 부른다 (TipTap 은 이 청크에만 있다) */
export const loadMailEditor = () => import('./MailEditor.vue')
/** 편집기를 못 불러왔을 때 "다시 시도" — 쓰기 화면이 provide 하고, 오류 화면(ComposeEditorLoadError)이 inject 한다 */
export const MAIL_EDITOR_RETRY: InjectionKey<() => void> = Symbol('mail-editor-retry')
@@ -0,0 +1,254 @@
// 서식 편집기(TipTap) 확장 구성 — 디자인 README "본문 서식 편집기 (TipTap)" · 계약 2절 "서식 본문"
// 편집기가 만드는 것 = 서버 정화 허용 목록(sanitize-outgoing-html.ts)에 있는 것만 둔다:
// 문단·줄바꿈, 굵게·기울임·밑줄·취소선, 글자 색·배경 색·글꼴·글자 크기, 정렬·들여쓰기, 목록, 인용, 가로줄, 표, 링크.
// 제목 스타일·코드·이미지·줄 간격은 두지 않는다 — 붙여 넣은 글에 있어도 스키마가 받지 않아 빠진다.
// 붙여 넣은 글 · 원문 인용의 서식 값은 서버 형식에 맞는 것만 읽는다 (mail-format-extensions.ts — 보이는 모양 = 보내는 모양).
import { Extension, type AnyExtension } from '@tiptap/core'
import { TableKit } from '@tiptap/extension-table'
import { TextAlign } from '@tiptap/extension-text-align'
import { TextStyle } from '@tiptap/extension-text-style'
import { Placeholder } from '@tiptap/extensions'
import type { EditorState, Transaction } from '@tiptap/pm/state'
import { StarterKit } from '@tiptap/starter-kit'
import {
MailBackgroundColor,
MailColor,
MailFontFamily,
MailFontSize,
MailTableCell,
MailTableHeader,
mailLink,
mailOrderedList,
starterKitPart,
} from '@/components/mail/editor/mail-format-extensions'
import {
EDITOR_INDENT_STEP_PX,
EDITOR_MAX_INDENT,
EDITOR_PLACEHOLDER,
MAIL_QUOTE_HEADING,
} from '@/shared/constants/mail-editor'
import { normalizeLinkHref, shouldAutoLinkText } from '@/shared/utils/link-href'
import { lengthToPx } from '@/shared/utils/outgoing-html-rules'
declare module '@tiptap/core' {
interface Commands<ReturnType> {
mailEditor: {
/** 들여쓰기 — 목록 안이면 한 단계 안으로, 그 밖은 문단 왼쪽 여백 */
indentBlock: () => ReturnType
/** 내어쓰기 */
outdentBlock: () => ReturnType
/** 서식 지우기 — 글자 서식 · 정렬 · 들여쓰기 (링크는 남긴다) */
clearFormatting: () => ReturnType
}
}
}
export interface MailEditorHooks {
/** Ctrl+K — 링크 넣기·고치기 */
openLink: (() => void) | null
/** Alt+F10 — 툴바로 간다 */
focusToolbar: (() => void) | null
}
/**
* 문단의 들여쓰기 단계 — 왼쪽 여백(px · pt)을 단계로 되돌린다 (붙여 넣은 글 포함, 줄임형 margin 도 브라우저가 풀어 읽는다).
* 기준 크기를 모르는 em · % 와 음수는 들여쓰지 않은 것으로 본다. 그릴 때는 늘 단계 × 40px 이라 서버 형식 안이다.
*/
function indentFromElement(element: HTMLElement): number {
const margin = lengthToPx(element.style.marginLeft)
if (margin === null || margin <= 0) return 0
return Math.min(EDITOR_MAX_INDENT, Math.round(margin / EDITOR_INDENT_STEP_PX))
}
/**
* 인용 머리인가 — 우리가 만든 초안은 data-quote-header 로 표시한다.
* 보낸 메일은 서버 정화가 data-* 를 지우므로, 답장의 답장처럼 다시 인용할 때는 "원본 메일" 글자와 바로 뒤 표로 알아본다.
*/
function isQuoteHeadingParagraph(element: HTMLElement): boolean {
return (
element.textContent?.trim() === MAIL_QUOTE_HEADING &&
element.nextElementSibling?.tagName === 'TABLE'
)
}
function isQuoteHeaderTable(element: HTMLElement): boolean {
const previous = element.previousElementSibling
return previous?.tagName === 'P' && previous.textContent?.trim() === MAIL_QUOTE_HEADING
}
/** 참/거짓 표시 속성 — HTML 에서는 data-* 로 오간다 (renderHTML 은 노드의 속성 전체를 받는다) */
function flagAttribute(name: string, dataName: string, detect: (element: HTMLElement) => boolean) {
return {
default: false,
// Enter 로 나눈 다음 문단에는 표시를 넘기지 않는다
keepOnSplit: false,
parseHTML: (element: HTMLElement) => element.hasAttribute(dataName) || detect(element),
renderHTML: (attributes: Record<string, unknown>) =>
attributes[name] ? { [dataName]: '' } : {},
}
}
/** 고른 범위의 문단 들여쓰기를 바꾼다 */
function changeIndent(state: EditorState, tr: Transaction, delta: number): boolean {
const { from, to } = state.selection
let changed = false
state.doc.nodesBetween(from, to, (node, pos) => {
if (node.type.name !== 'paragraph') return true
const current = Number(node.attrs.indent) || 0
const next = Math.max(0, Math.min(EDITOR_MAX_INDENT, current + delta))
if (next !== current) {
tr.setNodeMarkup(pos, undefined, { ...node.attrs, indent: next })
changed = true
}
return false
})
return changed
}
/** 우리 편집기만의 표시 · 명령 · 단축키 */
const MailEditorBehavior = Extension.create<MailEditorHooks>({
name: 'mailEditor',
addOptions() {
return { openLink: null, focusToolbar: null }
},
addGlobalAttributes() {
return [
{
types: ['paragraph'],
attributes: {
indent: {
default: 0,
// 들여 쓴 문단에서 Enter — 다음 줄도 같은 깊이 (워드 프로그램과 같다)
keepOnSplit: true,
parseHTML: indentFromElement,
renderHTML: (attributes: Record<string, unknown>) => {
const indent = Number(attributes.indent) || 0
return indent > 0 ? { style: `margin-left: ${indent * EDITOR_INDENT_STEP_PX}px` } : {}
},
},
quoteHeader: flagAttribute('quoteHeader', 'data-quote-header', isQuoteHeadingParagraph),
},
},
{
types: ['table'],
attributes: {
quoteHeader: flagAttribute('quoteHeader', 'data-quote-header', isQuoteHeaderTable),
},
},
{
types: ['blockquote'],
attributes: { mailQuote: flagAttribute('mailQuote', 'data-mail-quote', () => false) },
},
]
},
addCommands() {
return {
indentBlock:
() =>
({ editor, commands, state, tr, dispatch }) => {
if (editor.isActive('listItem')) return commands.sinkListItem('listItem')
const changed = changeIndent(state, tr, 1)
if (changed && dispatch) dispatch(tr)
return changed
},
outdentBlock:
() =>
({ editor, commands, state, tr, dispatch }) => {
if (editor.isActive('listItem')) return commands.liftListItem('listItem')
const changed = changeIndent(state, tr, -1)
if (changed && dispatch) dispatch(tr)
return changed
},
clearFormatting:
() =>
({ chain, state, tr }) => {
changeIndent(state, tr, -EDITOR_MAX_INDENT)
return chain()
.unsetMark('bold')
.unsetMark('italic')
.unsetMark('underline')
.unsetMark('strike')
.unsetMark('textStyle')
.unsetTextAlign()
.run()
},
}
},
addKeyboardShortcuts() {
return {
'Mod-k': () => {
this.options.openLink?.()
return true
},
// 더 들이거나 내어 쓸 수 없어도(0단계 · 최대 단계) 키는 여기서 끝낸다 — 맥에서 ⌘[ · ⌘] 는 브라우저의
// 뒤로 · 앞으로 가기라 넘기면 쓰던 메일을 잃는다
'Mod-]': () => {
this.editor.commands.indentBlock()
return true
},
'Mod-[': () => {
this.editor.commands.outdentBlock()
return true
},
'Mod-\\': () => {
this.editor.commands.clearFormatting()
return true
},
'Alt-F10': () => {
this.options.focusToolbar?.()
return true
},
}
},
})
/** StarterKit 설정 — 제목 · 코드는 두지 않는다 */
const STARTER_KIT_OPTIONS = { heading: false, code: false, codeBlock: false } as const
/**
* 링크 설정 — 링크 확장은 주소만 읽도록 바꿔 넣는다 (mail-format-extensions.ts 의 mailLink).
* 링크 주소는 늘 http · https · mailto 다: 링크 칸은 프로토콜을 붙여 넣고, 자동 링크 · 붙여 넣은 주소는 링크 확장이 붙인다
* (자동 링크는 https://, 메일 주소는 mailto:). 붙여 넣은 HTML 의 <a> 는 주소가 완전할 때만 링크로 읽는다 (mailLink).
*/
const LINK_OPTIONS = {
openOnClick: false,
autolink: true,
linkOnPaste: true,
defaultProtocol: 'https',
// 링크가 될 수 있는 글인가 — 프로토콜이 없으면 붙여 본다 (www.naver.com · 메일 주소). javascript: 등은 안 된다
isAllowedUri: (url: string) => normalizeLinkHref(url) !== null,
// 쓰는 중의 자동 링크 — 프로토콜 없이 쓴 IP 주소(버전 번호 등)는 빼고
shouldAutoLink: (url: string) => shouldAutoLinkText(url),
// 새 창으로 연다 — 붙여 넣은 링크의 target · rel 은 읽지 않고 늘 이 값으로 그린다
HTMLAttributes: { rel: 'noopener noreferrer nofollow', target: '_blank' },
}
/** 편집기 확장 목록 — 화면의 편집기와 초안 맞춤(fit-draft)·테스트가 같은 스키마를 쓴다 */
export function createMailEditorExtensions(hooks: Partial<MailEditorHooks> = {}): AnyExtension[] {
const kitWithLink = StarterKit.configure({ ...STARTER_KIT_OPTIONS, link: LINK_OPTIONS })
return [
// 링크 · 번호 목록은 속성 읽기를 바꾼 것으로 따로 넣는다 (mail-format-extensions.ts)
StarterKit.configure({ ...STARTER_KIT_OPTIONS, link: false, orderedList: false }),
mailLink(starterKitPart(kitWithLink, 'link')),
mailOrderedList(starterKitPart(kitWithLink, 'orderedList')),
TextStyle,
MailColor,
MailBackgroundColor,
MailFontFamily,
MailFontSize,
TextAlign.configure({ types: ['paragraph'], alignments: ['left', 'center', 'right'] }),
TableKit.configure({ table: { resizable: false }, tableCell: false, tableHeader: false }),
MailTableCell,
MailTableHeader,
Placeholder.configure({ placeholder: EDITOR_PLACEHOLDER }),
MailEditorBehavior.configure({
openLink: hooks.openLink ?? null,
focusToolbar: hooks.focusToolbar ?? null,
}),
]
}
@@ -0,0 +1,234 @@
import { Editor, createDocument, getHTMLFromFragment, getSchema, type JSONContent } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { afterEach, describe, expect, it } from 'vitest'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
import type { MessageDetail } from '@/shared/types/mail'
import { buildForwardDraft } from '@/shared/utils/mail-forward'
import { isOutgoingStyleValue } from '@/shared/utils/outgoing-html-rules'
import { STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
const schema = getSchema(createMailEditorExtensions())
/** 붙여 넣기 · 원문 인용처럼 편집기로 읽는다 */
function parse(html: string): ProseMirrorNode {
return createDocument(normalizeIncomingHtml(html), schema)
}
/** 보낼 서식 본문 */
function outgoingHtml(doc: ProseMirrorNode): string {
return toMailHtml(getHTMLFromFragment(doc.content, schema))
}
/** 문서 안 첫 textStyle 의 값 */
function firstTextStyle(doc: ProseMirrorNode): Record<string, unknown> | null {
let found: Record<string, unknown> | null = null
const walk = (node: JSONContent) => {
node.marks?.forEach((mark) => {
if (!found && mark.type === 'textStyle') found = mark.attrs ?? {}
})
node.content?.forEach(walk)
}
walk(doc.toJSON() as JSONContent)
return found
}
/** HTML 의 모든 요소 — 떨어진 문서에서 읽는다 */
function elementsOf(html: string): Element[] {
return Array.from(new DOMParser().parseFromString(html, 'text/html').body.querySelectorAll('*'))
}
/** HTML 의 모든 style 선언 [속성, 값] */
function styleDeclarations(html: string): [string, string][] {
return elementsOf(html).flatMap((element) =>
(element.getAttribute('style') ?? '')
.split(';')
.map((declaration) => declaration.trim())
.filter(Boolean)
.map((declaration): [string, string] => {
const colon = declaration.indexOf(':')
return [declaration.slice(0, colon).trim(), declaration.slice(colon + 1).trim()]
}),
)
}
const TEXT_STYLE_ATTRIBUTES: Readonly<Record<string, string>> = {
color: 'color',
'background-color': 'backgroundColor',
'font-family': 'fontFamily',
'font-size': 'fontSize',
}
describe('글자 서식 값 — 서버가 남기는 형식만 읽는다 (보이는 모양 = 보내는 모양)', () => {
const textStyleSamples = STYLE_SAMPLES.filter(([property]) => property in TEXT_STYLE_ATTRIBUTES)
it.each(textStyleSamples)('%s: %s (서버가 남기는가 %s)', (property, value, kept) => {
const attribute = TEXT_STYLE_ATTRIBUTES[property] ?? ''
const read = firstTextStyle(parse(`<p><span style="${property}: ${value}">a</span></p>`))?.[attribute] ?? null
// 서버가 남기는 값은 그대로 읽고, 편집기가 읽은 값은 늘 서버 형식 안이다 (pt · 긴 소수는 px 로 맞춘 값)
if (kept) expect(read).toBe(value)
expect(read === null || (typeof read === 'string' && isOutgoingStyleValue(property, read))).toBe(true)
})
it('숨기는 값(font-size:0 · color:transparent)은 읽지 않는다 — 편집기에서도 보인다', () => {
const doc = parse('<p><span style="font-size: 0">숨은 글</span><span style="color: transparent">투명 글</span></p>')
expect(firstTextStyle(doc)).toEqual({ color: null, backgroundColor: null, fontFamily: null, fontSize: null })
expect(outgoingHtml(doc)).not.toMatch(/font-size: 0|transparent/)
})
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')
expect(firstTextStyle(parse('<p><span style="color: #B42318 !important">a</span></p>'))?.color).toBe('#B42318')
})
it('줄임형(background)으로 적힌 색도 브라우저가 풀어 읽은 값으로 본다 — 형식이 맞을 때만', () => {
expect(firstTextStyle(parse('<p><span style="background: rgb(255, 241, 168)">a</span></p>'))?.backgroundColor).toBe(
'rgb(255, 241, 168)',
)
expect(firstTextStyle(parse('<p><span style="background: yellow">a</span></p>'))?.backgroundColor).toBeNull()
})
})
describe('전달하는 원문의 숨은 글 — 편집기에서 보이고, 받는 사람에게도 같게 간다', () => {
const ORIGINAL = {
id: 'm1',
subject: '견적서',
from: { name: '외부 업체', email: 'vendor@example.com' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
cc: [],
sentAt: '2026-10-26T01:00:00.000Z',
receivedAt: '2026-10-26T01:00:00.000Z',
textBody: null,
htmlBody:
'<p>견적서 보내 드립니다.<span style="font-size:0">※ 입금 계좌가 변경되었습니다: 000-000</span>' +
'<span style="color:transparent">투명 글</span><a class="visually-hidden" href="https://phish.example">숨은 링크</a></p>',
attachments: [],
} as unknown as MessageDetail
let editor: Editor | null = null
afterEach(() => {
editor?.destroy()
editor = null
})
it('편집 영역(화면) · 보내는 HTML 모두 숨기는 서식이 없다', () => {
const draft = buildForwardDraft(ORIGINAL, 'Asia/Seoul')
editor = new Editor({
element: document.createElement('div'),
extensions: createMailEditorExtensions(),
content: normalizeIncomingHtml(draft.html),
})
const live = (editor.view.dom as HTMLElement).innerHTML
const sent = toMailHtml(editor.getHTML())
for (const html of [live, sent]) {
expect(html).not.toMatch(/font-size: 0|transparent|visually-hidden/)
}
expect(docToText(editor.state.doc)).toContain('※ 입금 계좌가 변경되었습니다: 000-000')
})
})
describe('보내는 HTML 의 style 은 모두 서버 형식 안이다 — 서버가 지워 모양이 바뀌는 일이 없다', () => {
it('워드 · 웹에서 붙여 넣은 서식 (형식이 틀린 값 섞임)', () => {
const doc = parse(
[
'<p style="margin-left: 2em; text-align: justify; line-height: 30px">',
'<span style="font-size: 0">0</span> <span style="font-size: 1.2em">em</span> ',
'<span style="font-size: 13.3333px; font-family: Calibri, sans-serif">calibri</span> ',
'<span style="font-size: 10.0pt; font-family: \'맑은 고딕\'">워드</span> ',
'<span style="color: red; background-color: yellow; line-height: 30px">이름 색</span> ',
'<span style="color: rgb(23, 92, 211); background-color: #FFF1A8">팔레트</span>',
'</p>',
'<p style="margin-left: 36pt">들여쓰기</p>',
'<ol type="a" start="99999"><li><p>번호</p></li></ol>',
'<table><colgroup><col width="5000"><col width="120"></colgroup><tbody>',
'<tr><td colspan="2;">칸</td><td>칸2</td></tr></tbody></table>',
].join(''),
)
const sent = outgoingHtml(doc)
const declarations = styleDeclarations(sent)
expect(declarations.length).toBeGreaterThan(10)
for (const [property, value] of declarations) {
expect(isOutgoingStyleValue(property, value), `${property}: ${value}`).toBe(true)
}
// 받아들인 값은 남는다
expect(sent).toContain('font-size: 13.33px')
expect(sent).toContain('margin-left: 40px')
expect(sent).toContain('color: rgb(23, 92, 211)')
})
it('서버가 지우는 속성도 그리지 않는다 — 링크의 class · title, 목록 기호 모양(type)', () => {
const sent = outgoingHtml(
parse(
'<p><a class="visually-hidden" title="설명" target="_self" rel="opener" href="https://x.kr">링크</a></p>' +
'<ol type="i" style="list-style-type: lower-alpha"><li><p>하나</p></li></ol>',
),
)
const attributes = new Set(elementsOf(sent).flatMap((element) => element.getAttributeNames()))
expect([...attributes].sort()).toEqual(['href', 'rel', 'style', 'target'])
expect(sent).toContain('<a target="_blank" rel="noopener noreferrer nofollow" href="https://x.kr">링크</a>')
})
})
describe('링크 · 목록 · 표 · 들여쓰기 값', () => {
it('링크는 주소만 읽는다 — 새 창 속성은 늘 같은 값', () => {
const doc = parse('<p><a class="visually-hidden" title="t" target="_self" rel="opener" href="https://x.kr">링크</a></p>')
const link = doc.firstChild?.firstChild?.marks.find((mark) => mark.type.name === 'link')
expect(link?.attrs).toEqual({ href: 'https://x.kr' })
})
it.each(['www.naver.com', '/docs/page', '#top', 'javascript:alert(1)', 'ftp://x.kr'])(
'붙여 넣은 <a> 의 주소가 완전한 http · https · mailto 가 아니면 글자만 남긴다 (서버도 그렇다): %s',
(href) => {
const doc = parse(`<p><a href="${href}">글자</a></p>`)
expect(outgoingHtml(doc)).not.toContain('<a ')
expect(docToText(doc)).toBe('글자')
},
)
it('번호 목록 — 시작 번호는 0~9999 만(그 밖은 1), 목록 기호 모양은 읽지 않는다. 텍스트 대체 본문도 같은 번호', () => {
const doc = parse('<ol type="A" start="3"><li><p>셋</p></li></ol><ol start="99999"><li><p>하나</p></li></ol>')
expect(doc.child(0).attrs).toMatchObject({ start: 3, type: null })
expect(doc.child(1).attrs).toMatchObject({ start: 1 })
expect(docToText(doc)).toBe('3. 셋\n1. 하나')
expect(outgoingHtml(doc)).toContain('<ol start="3" style="margin: 0; padding-left: 24px">')
expect(outgoingHtml(doc)).not.toContain('type=')
})
it('표 칸 — 칸 합치기는 숫자만, 칸 폭은 999px 이하만', () => {
const doc = parse(
'<table><colgroup><col width="5000"><col width="120"></colgroup><tbody>' +
'<tr><td colspan="2;">a</td><td rowspan="abc">b</td></tr></tbody></table>',
)
const row = doc.child(0).child(0)
expect(row.child(0).attrs).toMatchObject({ colspan: 1, rowspan: 1, colwidth: null })
expect(row.child(1).attrs).toMatchObject({ colspan: 1, rowspan: 1, colwidth: [120] })
})
it('들여쓰기 — px · pt · 줄임형 margin 은 단계로, em · % · 음수는 0', () => {
const indents = (html: string) => {
const doc = parse(html)
const values: unknown[] = []
doc.forEach((child) => values.push(child.attrs.indent))
return values
}
expect(
indents(
'<p style="margin-left: 40px">a</p><p style="margin-left: 36pt">b</p><p style="margin: 0 0 0 80px">c</p>' +
'<p style="margin-left: 2em">d</p><p style="margin-left: 50%">e</p><p style="margin-left: -40px">f</p>',
),
).toEqual([1, 1, 2, 0, 0, 0])
})
})
@@ -0,0 +1,177 @@
// 서식 편집기가 HTML 에서 읽어 들이는 서식 — 보내는 HTML 정화 허용 목록(서버)에 맞는 값만 읽고, 맞는 값만 그린다.
// 값 형식: shared/utils/outgoing-html-rules.ts (서버 sanitize-outgoing-html.ts 와 같다).
//
// 왜: 붙여 넣은 글 · 원문 인용의 서식을 편집기가 그대로 받으면, 서버가 지우는 값이 편집기에서만 효과를 낸다.
// 예) 받은 메일의 <span style="font-size:0">입금 계좌가 바뀌었습니다</span> 를 전달하면 편집기에서는 숨어 보이지 않는데,
// 서버가 font-size:0 을 지워 받는 사람에게는 보인다. color:transparent · 색 이름 · em 크기 · 목록 밖 글꼴도 같다.
// 그래서 읽을 때(parse) 버린다 — 편집기에 보이는 모양 = 보내는 모양.
// - 글자 색 · 배경 색 · 글꼴 · 글자 크기: 형식이 맞는 값만 (글자 크기의 pt 는 px 로 바꾼다)
// - 링크: 주소(href)가 완전한 http · https · mailto 인 <a> 만, 주소만 읽는다. target · rel 은 그릴 때 늘 같은 값이고
// (HTMLAttributes), class · title 은 받지 않는다 (붙여 넣은 <a class="visually-hidden"> 이 편집기에서만 숨지 않게)
// - 번호 목록: 시작 번호는 서버가 남기는 숫자만, 목록 기호 모양(a · i · 로마 숫자)은 받지 않는다 (서버가 type 을 지운다)
// - 표 칸: 칸 합치기는 숫자만, 칸 폭(colwidth)은 서버가 남기는 px(999 이하)만
import {
Mark,
Node,
flattenExtensions,
getStyleProperty,
type AnyExtension,
type Attribute,
type Extension,
} from '@tiptap/core'
import { TableCell, TableHeader } from '@tiptap/extension-table'
import { BackgroundColor, Color, FontFamily, FontSize } from '@tiptap/extension-text-style'
import { isAllowedLinkHref } from '@/shared/utils/link-href'
import {
acceptStyleValue,
cellSpanOf,
listStartOf,
type OutgoingStyleProperty,
} from '@/shared/utils/outgoing-html-rules'
// ── 글자 서식 (textStyle 의 style) ───────────────────────────
type TextStyleName = 'color' | 'backgroundColor' | 'fontFamily' | 'fontSize'
const TEXT_STYLE_PROPERTIES = {
color: 'color',
backgroundColor: 'background-color',
fontFamily: 'font-family',
fontSize: 'font-size',
} as const satisfies Record<TextStyleName, OutgoingStyleProperty>
/**
* 요소의 style 값 — 적힌 그대로를 먼저(#rrggbb · 따옴표 모양 유지), 없으면 브라우저가 읽은 값
* (줄임형 font · background 로 적힌 경우). 어느 쪽이든 보낼 수 있는 형식이 아니면 null.
*/
function readStyleValue(element: HTMLElement, property: OutgoingStyleProperty): string | null {
const written = getStyleProperty(element, property)
const parsed = element.style.getPropertyValue(property)
return acceptStyleValue(property, written ?? (parsed === '' ? null : parsed))
}
function textStyleAttribute(name: TextStyleName): Attribute {
const property = TEXT_STYLE_PROPERTIES[name]
return {
default: null,
parseHTML: (element: HTMLElement) => readStyleValue(element, property),
// 명령 · 저장된 내용(JSON)으로 들어온 값도 형식이 맞을 때만 그린다 — 화면과 보내는 HTML 이 같은 값을 쓴다
renderHTML: (attributes: Record<string, unknown>) => {
const raw = attributes[name]
const value = acceptStyleValue(property, typeof raw === 'string' ? raw : null)
return value === null ? {} : { style: `${property}: ${value}` }
},
}
}
/** 글자 서식 확장의 읽기 · 그리기만 바꾼다 — 명령(setColor 등)은 그대로 쓴다 */
function withTextStyleAttribute<Options extends { types: string[] }>(
extension: Extension<Options>,
name: TextStyleName,
): Extension<Options> {
return extension.extend({
addGlobalAttributes() {
return [{ types: this.options.types, attributes: { [name]: textStyleAttribute(name) } }]
},
})
}
export const MailColor = withTextStyleAttribute(Color, 'color')
export const MailBackgroundColor = withTextStyleAttribute(BackgroundColor, 'backgroundColor')
export const MailFontFamily = withTextStyleAttribute(FontFamily, 'fontFamily')
export const MailFontSize = withTextStyleAttribute(FontSize, 'fontSize')
// ── StarterKit 의 링크 · 번호 목록 ───────────────────────────
/**
* StarterKit 안의 확장 하나 — 같은 설정 · 같은 버전 그대로 꺼내 속성 읽기만 바꾼다.
* (링크 · 목록 패키지를 따로 의존성에 넣지 않는다 — @tiptap/* 는 모두 같은 버전으로 고정한다)
*/
export function starterKitPart(kit: AnyExtension, name: string): AnyExtension {
const part = flattenExtensions([kit]).find((extension) => extension.name === name)
if (!part) throw new Error(`StarterKit 에 ${name} 확장이 없다`)
return part
}
/**
* 링크 — 주소만 읽는다. 붙여 넣은 HTML 의 <a> 는 주소가 이미 완전한 http · https · mailto 일 때만 링크로 읽는다
* (서버도 그 밖의 링크는 글자만 남긴다). 상대 주소(/docs · www.x.kr)에 프로토콜을 붙여 링크로 만들지 않는다 — 엉뚱한 곳을 가리킨다.
* 쓰는 중의 자동 링크 · 붙여 넣은 주소 글에는 링크 확장이 프로토콜을 붙인다 (mail-extensions.ts 의 LINK_OPTIONS).
*/
export function mailLink(kitLink: AnyExtension): AnyExtension {
if (!(kitLink instanceof Mark)) throw new Error('링크 확장이 Mark 가 아니다')
return kitLink.extend({
parseHTML() {
return [
{
tag: 'a[href]',
getAttrs: (element: HTMLElement) => (isAllowedLinkHref(element.getAttribute('href') ?? '') ? null : false),
},
]
},
addAttributes() {
return {
href: { default: null, parseHTML: (element: HTMLElement) => element.getAttribute('href') },
}
},
})
}
/** 번호 목록 — 시작 번호는 0~9999 만(그 밖은 1), 목록 기호 모양은 읽지도 그리지도 않는다 */
export function mailOrderedList(kitList: AnyExtension): AnyExtension {
if (!(kitList instanceof Node)) throw new Error('번호 목록 확장이 Node 가 아니다')
return kitList.extend({
addAttributes() {
return {
start: {
default: 1,
parseHTML: (element: HTMLElement) => listStartOf(element.getAttribute('start')),
// 입력 규칙("12345. ")으로 들어온 값도 — 텍스트 대체 본문(docToText)도 같은 listStartOf 를 쓴다
renderHTML: (attributes: Record<string, unknown>) => ({ start: listStartOf(attributes.start) }),
},
type: { default: null, parseHTML: () => null, rendered: false },
}
},
})
}
// ── 표 칸 ────────────────────────────────────────────
/** 칸 폭 상한(px) — 서버가 남기는 px 길이(999.99 이하) */
const COLUMN_WIDTH_MAX_PX = 999
/** 칸 폭 목록 — 1~999px 정수만 남기고(나머지는 폭 없음 0), 하나도 없으면 null */
function columnWidthsOf(value: unknown): number[] | null {
if (!Array.isArray(value)) return null
const widths = value.map((width: unknown) =>
typeof width === 'number' && Number.isInteger(width) && width >= 1 && width <= COLUMN_WIDTH_MAX_PX ? width : 0,
)
return widths.some((width) => width > 0) ? widths : null
}
function cellAttributes(parent: Record<string, Attribute>): Record<string, Attribute> {
const colwidth = parent.colwidth
return {
...parent,
colspan: { default: 1, parseHTML: (element: HTMLElement) => cellSpanOf(element.getAttribute('colspan')) },
rowspan: { default: 1, parseHTML: (element: HTMLElement) => cellSpanOf(element.getAttribute('rowspan')) },
colwidth: {
...colwidth,
default: null,
parseHTML: (element: HTMLElement) => columnWidthsOf(colwidth?.parseHTML?.(element)),
},
}
}
export const MailTableCell = TableCell.extend({
addAttributes() {
return cellAttributes((this.parent?.() ?? {}) as Record<string, Attribute>)
},
})
export const MailTableHeader = TableHeader.extend({
addAttributes() {
return cellAttributes((this.parent?.() ?? {}) as Record<string, Attribute>)
},
})
@@ -0,0 +1,260 @@
import { createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import {
MAIL_HTML_STYLES,
docToText,
normalizeIncomingHtml,
toMailHtml,
toMailHtmlFragment,
} from '@/components/mail/editor/mail-serialize'
import { MAIL_BODY_LINE_HEIGHT } from '@/shared/constants/mail-editor'
const schema = getSchema(createMailEditorExtensions())
function text(html: string): string {
return docToText(createDocument(html, schema))
}
/**
* 다른 문서(떨어진 문서)의 노드가 화면 문서로 들어오는지 지켜본다.
* 브라우저에서는 <img> 가 화면 문서로 입양되는 순간 내려받기를 시작해 onerror 가 돈다 (2026-10-27 실측).
* jsdom 은 그림을 내려받지 않아 처리기 실행으로는 잡을 수 없어, 실행의 전제인 "입양"을 잡는다 —
* 노드를 넣는 DOM 메서드와 importNode · adoptNode 를 감싸, 화면 문서 쪽으로 다른 문서의 노드가 오면 기록한다.
*/
function watchAdoptionIntoLiveDocument() {
const live = document
const adopted: string[] = []
const restores: (() => void)[] = []
const isForeign = (value: unknown) =>
value instanceof Node && !(value instanceof Document) && value.ownerDocument !== live
const isLive = (target: unknown) => target === live || (target instanceof Node && target.ownerDocument === live)
const wrap = (prototype: object, name: string) => {
const original: unknown = Reflect.get(prototype, name)
if (typeof original !== 'function') return
Reflect.set(prototype, name, function (this: unknown, ...args: unknown[]) {
if (isLive(this) && args.some(isForeign)) adopted.push(name)
return Reflect.apply(original, this, args)
})
restores.push(() => Reflect.set(prototype, name, original))
}
for (const name of ['appendChild', 'insertBefore', 'replaceChild']) wrap(Node.prototype, name)
for (const prototype of [Element.prototype, DocumentFragment.prototype, Document.prototype]) {
for (const name of ['append', 'prepend', 'replaceChildren']) wrap(prototype, name)
}
for (const prototype of [Element.prototype, CharacterData.prototype]) {
for (const name of ['before', 'after', 'replaceWith']) wrap(prototype, name)
}
for (const name of ['importNode', 'adoptNode']) wrap(Document.prototype, name)
return { adopted, restore: () => restores.reverse().forEach((restore) => restore()) }
}
describe('docToText — 서식 본문의 대체 텍스트 (계약 2절 "서식 본문")', () => {
it('문단은 한 줄씩, 줄바꿈(Shift+Enter)은 줄로, 끝의 빈 줄은 뺀다', () => {
expect(text('<p>첫 줄</p><p></p><p>둘째<br>셋째</p><p></p>')).toBe('첫 줄\n\n둘째\n셋째')
})
it('목록은 "- " · "1. " (시작 번호를 따른다), 이어지는 줄은 들여 쓴다', () => {
expect(text('<ul><li><p>사과</p></li><li><p>배</p><p>설명</p></li></ul>')).toBe('- 사과\n- 배\n 설명')
expect(text('<ol start="3"><li><p>셋</p></li><li><p>넷</p></li></ol>')).toBe('3. 셋\n4. 넷')
})
it('인용은 줄마다 "> " — 빈 줄은 ">"', () => {
expect(text('<blockquote><p>인용 1</p><p></p><p>인용 2</p></blockquote>')).toBe('> 인용 1\n>\n> 인용 2')
})
it('표는 칸을 " | " 로 잇고, "원본 메일" 머리 표는 "항목: 값" 뒤에 한 줄 띄운다', () => {
expect(text('<table><tbody><tr><td><p>이름</p></td><td><p>값</p></td></tr></tbody></table>')).toBe('이름 | 값')
expect(
text(
'<blockquote data-mail-quote=""><p data-quote-header=""><strong>원본 메일</strong></p>' +
'<table data-quote-header=""><tbody><tr><td>제목</td><td>일정</td></tr></tbody></table><p>원문</p></blockquote>',
),
).toBe('> 원본 메일\n> 제목: 일정\n>\n> 원문')
})
it('링크는 글자와 주소가 다르면 "글자 (주소)", 같으면 글자만', () => {
expect(text('<p><a href="https://wiki.example.kr/a">안내</a> · <a href="https://x.kr">https://x.kr</a></p>')).toBe(
'안내 (https://wiki.example.kr/a) · https://x.kr',
)
expect(text('<p><a href="mailto:ara@arasystem.it.kr">ara@arasystem.it.kr</a></p>')).toBe('ara@arasystem.it.kr')
})
it('가로줄은 "---"', () => {
expect(text('<p>위</p><hr><p>아래</p>')).toBe('위\n---\n아래')
})
})
describe('toMailHtml — 받는 쪽 모양을 인라인 style 로 (문단 사이 여백 0)', () => {
it('본문 전체를 맑은 고딕 · 14px · 줄 간격 1.75 div 로 감싼다 (편집기 화면과 같은 값)', () => {
expect(toMailHtml('<p>안녕</p>')).toBe(
`<div style="font-family: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif; font-size: 14px; line-height: ${MAIL_BODY_LINE_HEIGHT}">` +
'<p style="margin: 0">안녕</p></div>',
)
expect(MAIL_BODY_LINE_HEIGHT).toBe(1.75)
})
it('빈 문단에도 한 줄 높이를 남긴다(<br>) — 끝에 남은 빈 문단은 보내지 않는다', () => {
expect(toMailHtmlFragment('<p>위</p><p></p><p>아래</p>')).toBe(
'<p style="margin: 0">위</p><p style="margin: 0"><br></p><p style="margin: 0">아래</p>',
)
expect(toMailHtml('<p>끝</p><p></p><p></p>')).not.toContain('<br>')
})
it('편집기가 넣은 정렬 · 들여쓰기는 기본 여백 뒤에 두어 이긴다', () => {
expect(toMailHtmlFragment('<p style="text-align: center; margin-left: 40px">가운데</p>')).toBe(
'<p style="margin: 0; text-align: center; margin-left: 40px">가운데</p>',
)
})
it('인용 막대 · 원문 인용 · 목록 · 표 · 가로줄도 모양을 싣는다', () => {
const html = toMailHtmlFragment(
'<blockquote><p>a</p></blockquote>' +
'<blockquote data-mail-quote=""><p>b</p></blockquote>' +
'<ol><li><p>c</p></li></ol>' +
'<table><tbody><tr><td><p>d</p></td></tr></tbody></table>' +
'<hr>',
)
expect(html).toContain(`<blockquote style="${MAIL_HTML_STYLES.quote}">`)
expect(html).toContain(`<blockquote style="${MAIL_HTML_STYLES.mailQuote}">`)
expect(html).toContain(`<ol style="${MAIL_HTML_STYLES.list}">`)
expect(html).toContain(`<table style="${MAIL_HTML_STYLES.table}">`)
expect(html).toContain(`<td style="${MAIL_HTML_STYLES.cell}">`)
expect(html).toContain(`<hr style="${MAIL_HTML_STYLES.rule}">`)
expect(html).not.toContain('data-mail-quote')
})
it('여러 칸 띄어쓰기 · 줄 앞 공백 — 받는 쪽에서도 보이게 nbsp 로 (보통 공백과 번갈아, 단어 사이 한 칸은 그대로)', () => {
const NBSP = ' '
expect(toMailHtmlFragment('<p> 1. 일시 : 10월 27일</p>')).toBe(
`<p style="margin: 0">${NBSP} 1. 일시 ${NBSP} ${NBSP}: 10월 27일</p>`.replace(/ /g, '&nbsp;'),
)
// 서식 경계를 넘어 이어진 공백 · 줄바꿈(<br>) 뒤의 줄 앞 공백
expect(toMailHtmlFragment('<p>a <strong> b</strong><br> c</p>')).toBe(
'<p style="margin: 0">a <strong>&nbsp;b</strong><br>&nbsp;c</p>',
)
// 탭은 다음 탭 칸(8칸)까지의 공백 — 줄 앞이면 8칸, "ab" 뒤면 6칸
expect(toMailHtmlFragment('<p>\tx</p><p>ab\ty</p>')).toBe(
'<p style="margin: 0">&nbsp; &nbsp; &nbsp; &nbsp; x</p><p style="margin: 0">ab &nbsp; &nbsp; &nbsp;y</p>',
)
expect(toMailHtmlFragment('<p>한 칸 띄어쓰기</p>')).toBe('<p style="margin: 0">한 칸 띄어쓰기</p>')
})
it('텍스트 대체 본문(body)의 공백은 그대로다', () => {
// 쓴 글처럼 공백을 그대로 둔 문서 (HTML 을 읽을 때의 공백 줄이기를 끈다)
const doc = createDocument('<p> 1. 일시 : 10월 27일</p><p>\tx</p>', schema, { preserveWhitespace: 'full' })
expect(docToText(doc)).toBe(' 1. 일시 : 10월 27일\n\tx')
})
it('"원본 메일" 머리 — 칸 테두리 없이 항목 이름은 흐린 글자, 표시(data-*)는 지운다', () => {
const html = toMailHtmlFragment(
'<p data-quote-header=""><strong>원본 메일</strong></p>' +
'<table data-quote-header=""><tbody><tr><td><p>제목</p></td><td><p>일정</p></td></tr></tbody></table>',
)
expect(html).toBe(
`<p style="margin: 0; ${MAIL_HTML_STYLES.quoteHeading}"><strong>원본 메일</strong></p>` +
`<table style="${MAIL_HTML_STYLES.quoteHeaderTable}"><tbody><tr>` +
`<td style="${MAIL_HTML_STYLES.quoteHeaderLabel}"><p style="margin: 0">제목</p></td>` +
`<td style="${MAIL_HTML_STYLES.quoteHeaderValue}"><p style="margin: 0">일정</p></td>` +
'</tr></tbody></table>',
)
})
})
describe('남의 HTML 을 화면 문서로 옮기지 않는다 (붙여 넣기 · 보낼 모양 — 처리기 실행 방지)', () => {
afterEach(() => {
vi.restoreAllMocks()
})
const HOSTILE = '<p>a<br></p><div><br></div><img src="x" onerror="window.__probe=1"><svg onload="window.__probe=1"></svg>'
it.each([
['normalizeIncomingHtml', () => normalizeIncomingHtml(HOSTILE)],
['toMailHtmlFragment', () => toMailHtmlFragment(`<p></p>${HOSTILE}`)],
['toMailHtml', () => toMailHtml(`<p></p>${HOSTILE}`)],
])('%s — 떨어진 문서에서만 다루고, 새 노드(p · br)도 화면 문서에서 만들지 않는다', (_name, run) => {
const watch = watchAdoptionIntoLiveDocument()
const createElement = vi.spyOn(document, 'createElement')
try {
run()
} finally {
watch.restore()
}
expect(watch.adopted).toEqual([])
// 화면 문서에서 만드는 것은 파싱 그릇인 <template> 뿐이다 (그 내용은 떨어진 문서에 만들어진다)
expect(new Set(createElement.mock.calls.map(([tag]) => tag))).toEqual(new Set(['template']))
})
it('지켜보는 장치가 고치기 전 방식(떨어진 문서에서 파싱 → 화면 문서의 div 로 옮김)을 잡는다', () => {
const watch = watchAdoptionIntoLiveDocument()
try {
const template = document.createElement('template')
template.innerHTML = HOSTILE
document.createElement('div').append(template.content)
} finally {
watch.restore()
}
expect(watch.adopted).toEqual(['append'])
})
it('출력은 그대로다 — 처리기 속성은 이 단계에서 지우지 않는다 (편집기 스키마 · 서버 정화가 지운다)', () => {
expect(normalizeIncomingHtml('<p>a</p><img src="x" onerror="f()">')).toBe('<p>a</p><img src="x" onerror="f()">')
})
})
describe('normalizeIncomingHtml — 원문 인용 · 붙여 넣기', () => {
it('본문 글자는 바꾸지 않는다 — 글 속의 "font-size: 12pt" 는 그대로 (서식 값은 편집기가 style 속성에서만 읽는다)', () => {
expect(normalizeIncomingHtml('<p>본문 규격: font-size: 12pt 로 맞춰 주세요</p>')).toBe(
'<p>본문 규격: font-size: 12pt 로 맞춰 주세요</p>',
)
expect(normalizeIncomingHtml('<span style="font-size:10.0pt;color:red">a</span>')).toBe(
'<span style="font-size:10.0pt;color:red">a</span>',
)
})
it('편집기로 읽으면 style 의 pt 글자 크기만 px 로 바뀐다 — 글자는 그대로', () => {
const doc = createDocument(
normalizeIncomingHtml('<p><span style="font-size:10.0pt">글 속의 font-size: 12pt</span></p>'),
schema,
)
expect(docToText(doc)).toBe('글 속의 font-size: 12pt')
expect(toMailHtmlFragment(getHTMLFromFragment(doc.content, schema))).toContain(
'<span style="font-size: 13.33px;">글 속의 font-size: 12pt</span>',
)
})
it('블록 끝의 <br> 은 줄을 더하지 않으므로 뺀다 — 빈 줄(<p><br></p>)은 빈 문단 하나가 된다', () => {
expect(normalizeIncomingHtml('<p>글<br></p><p><br></p><p>a<br><br></p>')).toBe('<p>글</p><p></p><p>a<br></p>')
expect(normalizeIncomingHtml('<p><span style="color: red">글<br></span>\n</p>')).toBe(
'<p><span style="color: red">글</span>\n</p>',
)
})
it('<br> 만 있는 div · 목록 항목 · 표 칸은 그 자리에 빈 문단을 둔다 (Gmail 의 <div><br></div>)', () => {
expect(normalizeIncomingHtml('<div>첫 줄</div><div><br></div><div>셋째 줄</div>')).toBe(
'<div>첫 줄</div><div><p></p></div><div>셋째 줄</div>',
)
expect(normalizeIncomingHtml('<ul><li><br></li></ul><table><tbody><tr><td><br></td></tr></tbody></table>')).toBe(
'<ul><li><p></p></li></ul><table><tbody><tr><td><p></p></td></tr></tbody></table>',
)
})
it('편집기에서 빈 줄이 한 줄이다 — 우리가 보낸 메일(빈 문단에 <br>)을 다시 인용해도 늘지 않는다', () => {
const sent = toMailHtmlFragment('<p>위</p><p></p><p>아래</p>')
const doc = createDocument(normalizeIncomingHtml(sent), schema)
expect(docToText(doc)).toBe('위\n\n아래')
expect(doc.childCount).toBe(3)
})
})
@@ -0,0 +1,383 @@
// 편집기 내용 → 보내는 본문 — 계약 2절 "서식 본문 (bodyHtml)"
// - bodyHtml: 편집기 HTML 에 받는 쪽 기본 스타일에 기대지 않도록 모양을 인라인 style 로 넣고,
// 기본 글꼴 · 글자 크기 · 줄 간격을 가진 div 로 감싼다 (편집기 화면과 같은 값).
// (받는 메일 프로그램은 <p> 에 자기 여백을 준다 — 문단마다 margin:0 을 넣어 Enter 한 번이 한 줄로 보이게 한다)
// 여러 칸 띄어쓰기 · 줄 앞 공백도 받는 쪽에서 그대로 보이게 nbsp 로 바꾼다 (preserveSpaces).
// - body: 같은 내용의 일반 텍스트 — 목록은 "- " · "1. ", 인용은 "> ", 표는 칸을 " | " 로 잇는다. 공백은 그대로다.
// 서버는 받은 HTML 을 다시 허용 목록으로 정화한다(sanitize-outgoing-html.ts). 여기서 넣는 style 은 모두 그 목록 안의 값이다
// (shared/utils/outgoing-html-rules.ts — 서버와 같은 형식, outgoing-html-rules.spec.ts 가 지킨다).
// HTML 조각은 화면과 떨어진 문서에서만 다룬다 — 남의 HTML 의 처리기가 돌지 않게 (rewriteInert).
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { MAIL_QUOTE_LINE_PREFIX } from '@/shared/constants/mail'
import {
MAIL_BODY_FONT_FAMILY,
MAIL_BODY_FONT_SIZE_PX,
MAIL_BODY_LINE_HEIGHT,
} from '@/shared/constants/mail-editor'
import { listStartOf } from '@/shared/utils/outgoing-html-rules'
// ── 텍스트 ───────────────────────────────────────────────
const LIST_BULLET = '- '
const TABLE_CELL_SEPARATOR = ' | '
/** 인용 머리 표는 "항목: 값" 으로 읽힌다 */
const HEADER_SEPARATOR = ': '
const HORIZONTAL_RULE_TEXT = '---'
function linkHref(node: ProseMirrorNode): string | null {
const link = node.marks.find((mark) => mark.type.name === 'link')
const href: unknown = link?.attrs.href
return typeof href === 'string' && href !== '' ? href : null
}
/** 글 블록의 글자 — 줄바꿈(hardBreak)은 \n. 링크는 글자와 주소가 다르면 "글자 (주소)" */
function inlineText(block: ProseMirrorNode): string {
let out = ''
let runText = ''
let runHref: string | null = null
const flush = () => {
if (runHref && runHref !== runText && runHref !== `mailto:${runText}`) out += ` (${runHref})`
runText = ''
runHref = null
}
block.forEach((child) => {
const href = child.isText ? linkHref(child) : null
if (href !== runHref) flush()
runHref = href
if (child.isText) {
const text = child.text ?? ''
out += text
if (href) runText += text
} else if (child.type.name === 'hardBreak') {
out += '\n'
} else {
out += child.textContent
}
})
flush()
return out
}
function isQuoteHeaderTable(node: ProseMirrorNode): boolean {
return node.type.name === 'table' && Boolean(node.attrs.quoteHeader)
}
function childLines(node: ProseMirrorNode): string[] {
const lines: string[] = []
node.forEach((child) => {
lines.push(...blockLines(child))
// 인용 머리 표와 원문 사이를 한 줄 띄운다 (화면의 12px 간격)
if (isQuoteHeaderTable(child)) lines.push('')
})
return lines
}
function listLines(list: ProseMirrorNode, marker: (index: number) => string): string[] {
const lines: string[] = []
list.forEach((item, _offset, index) => {
const prefix = marker(index)
const continuation = ' '.repeat(prefix.length)
childLines(item).forEach((line, lineIndex) => {
lines.push(`${lineIndex === 0 ? prefix : continuation}${line}`)
})
})
return lines
}
function tableLines(table: ProseMirrorNode): string[] {
const separator = isQuoteHeaderTable(table) ? HEADER_SEPARATOR : TABLE_CELL_SEPARATOR
const lines: string[] = []
table.forEach((row) => {
const cells: string[] = []
row.forEach((cell) => cells.push(childLines(cell).join(' ').trim()))
lines.push(cells.join(separator))
})
return lines
}
/** 블록 하나의 텍스트 줄 */
export function blockLines(node: ProseMirrorNode): string[] {
switch (node.type.name) {
case 'paragraph':
return inlineText(node).split('\n')
case 'blockquote':
return childLines(node).map((line) =>
line === '' ? MAIL_QUOTE_LINE_PREFIX.trimEnd() : `${MAIL_QUOTE_LINE_PREFIX}${line}`,
)
case 'bulletList':
return listLines(node, () => LIST_BULLET)
case 'orderedList': {
// 서식 본문과 같은 시작 번호 — 서버가 남기지 않는 값이면 둘 다 1 부터 (mail-format-extensions.ts)
const start = listStartOf(node.attrs.start)
return listLines(node, (index) => `${start + index}. `)
}
case 'table':
return tableLines(node)
case 'horizontalRule':
return [HORIZONTAL_RULE_TEXT]
default:
return node.isTextblock ? inlineText(node).split('\n') : childLines(node)
}
}
/** 편집기 문서 → 일반 텍스트 본문 (끝의 빈 줄은 뺀다) */
export function docToText(doc: ProseMirrorNode): string {
return childLines(doc).join('\n').replace(/\s+$/, '')
}
// ── HTML ─────────────────────────────────────────────────
/** 받는 쪽에서도 편집기와 같은 모양이 되도록 넣는 인라인 style (디자인 README "답장 · 전달의 원문 인용" 값) */
const QUOTE_BAR = 'padding: 2px 0 2px 14px; border-left: 2px solid #D0D5DD; color: #4A5363'
const CELL_BORDER = '1px solid #D0D5DD'
export const MAIL_HTML_STYLES = {
paragraph: 'margin: 0',
list: 'margin: 0; padding-left: 24px',
quote: `margin: 0; ${QUOTE_BAR}`,
/** 원문 인용 — 내 글과 24 띄운다 */
mailQuote: `margin: 24px 0 0; ${QUOTE_BAR}`,
/** "원본 메일" — 문단 여백(margin: 0)은 모든 문단과 같이 붙는다 */
quoteHeading: 'font-size: 12.5px',
quoteHeaderTable: 'margin: 0 0 12px; border-collapse: collapse; font-size: 12.5px; line-height: 1.6',
quoteHeaderLabel: 'padding: 0 12px 0 0; color: #667085; vertical-align: top; white-space: nowrap',
quoteHeaderValue: 'padding: 0; vertical-align: top',
table: 'border-collapse: collapse',
cell: `border: ${CELL_BORDER}; padding: 4px 8px; vertical-align: top`,
rule: `margin: 12px 0; border: 0; border-top: ${CELL_BORDER}`,
} as const
/** 기본 style 을 앞에 둔다 — 편집기가 넣은 정렬·들여쓰기(margin-left)가 뒤에서 이긴다 */
function prependStyle(element: Element, style: string) {
const existing = element.getAttribute('style')?.trim()
element.setAttribute('style', existing ? `${style}; ${existing}` : style)
}
function styleAll(root: ParentNode, selector: string, style: string) {
root.querySelectorAll(selector).forEach((element) => prependStyle(element, style))
}
function isEmptyParagraph(element: Element): boolean {
return element.tagName === 'P' && element.textContent === '' && element.querySelector('br') === null
}
/**
* HTML 조각을 화면과 떨어진 문서에서 고치고 다시 문자열로 만든다 — 보안상 이 함수로만 조각을 다룬다.
* <template> 의 내용은 "템플릿 내용 문서"(브라우징 맥락이 없다)에 만들어져 그림을 내려받지 않고 처리기(onerror 등)도 돌지 않는다.
* 이 노드를 화면 문서의 요소로 옮기면(입양) 그 순간 <img src onerror> 가 내려받기를 시작해 처리기가 돈다
* (2026-10-27 실측 — 붙여 넣은 남의 HTML 이 여기를 지난다. 운영은 CSP 가 한 번 더 막지만 그것에만 기대지 않는다).
* 그래서 결과는 template.innerHTML 로 직렬화하고, 새로 만드는 노드도 같은 떨어진 문서(inert)에서 만든다.
*/
function rewriteInert(html: string, rewrite: (root: DocumentFragment, inert: Document) => void): string {
const template = document.createElement('template')
template.innerHTML = html
const root = template.content
// 조각에는 늘 주인 문서가 있다 — 없다면(일어나지 않는다) 새 떨어진 문서를 쓴다. 화면 문서로는 돌아가지 않는다
rewrite(root, root.ownerDocument ?? document.implementation.createHTMLDocument(''))
return template.innerHTML
}
/** 편집기 HTML 조각에 받는 쪽 모양을 입힌다 (감싸는 div 없이) */
export function toMailHtmlFragment(editorHtml: string): string {
return rewriteInert(editorHtml, applyMailStyles)
}
function applyMailStyles(root: DocumentFragment, inert: Document) {
// 원문 인용 머리 — 표시를 읽고 지운다 (서버도 data-* 를 지운다)
const headerTables = new Set(root.querySelectorAll('table[data-quote-header]'))
headerTables.forEach((table) => {
prependStyle(table, MAIL_HTML_STYLES.quoteHeaderTable)
table.querySelectorAll('tr').forEach((row) => {
const [label, value] = Array.from(row.children)
if (label) prependStyle(label, MAIL_HTML_STYLES.quoteHeaderLabel)
if (value) prependStyle(value, MAIL_HTML_STYLES.quoteHeaderValue)
})
table.removeAttribute('data-quote-header')
})
root.querySelectorAll('p[data-quote-header]').forEach((heading) => {
prependStyle(heading, MAIL_HTML_STYLES.quoteHeading)
heading.removeAttribute('data-quote-header')
})
root.querySelectorAll('blockquote').forEach((quote) => {
const isMailQuote = quote.hasAttribute('data-mail-quote')
prependStyle(quote, isMailQuote ? MAIL_HTML_STYLES.mailQuote : MAIL_HTML_STYLES.quote)
quote.removeAttribute('data-mail-quote')
})
// 표 — 인용 머리 표는 위에서 이미 꾸몄다 (칸 테두리 없음)
root.querySelectorAll('table').forEach((table) => {
if (headerTables.has(table)) return
prependStyle(table, MAIL_HTML_STYLES.table)
table.querySelectorAll('td, th').forEach((cell) => prependStyle(cell, MAIL_HTML_STYLES.cell))
})
styleAll(root, 'ul, ol', MAIL_HTML_STYLES.list)
styleAll(root, 'hr', MAIL_HTML_STYLES.rule)
root.querySelectorAll('p').forEach((paragraph) => {
// 빈 문단은 높이가 0 이 되기 쉽다 — 줄바꿈을 넣어 한 줄 높이를 지킨다
if (isEmptyParagraph(paragraph)) paragraph.append(inert.createElement('br'))
prependStyle(paragraph, MAIL_HTML_STYLES.paragraph)
preserveSpaces(paragraph, inert)
})
}
// ── 공백 ─────────────────────────────────────────────────
const NBSP = ' '
/** 탭이 닿는 칸 — 편집 영역의 탭 간격(브라우저 기본 tab-size 8)과 같다. 편집 영역 CSS 에서 바꾸지 않는다 */
const TAB_STOP = 8
/**
* 여러 칸 띄어쓰기 · 줄 앞 공백(탭 포함)이 받는 쪽에서도 보이게 한다.
* 편집기는 공백을 그대로 보이지만(white-space: break-spaces), 받는 쪽은 이어진 공백을 하나로 줄이고 줄 앞 공백을 지운다
* — 서버는 white-space 를 normal · nowrap 만 남겨 pre-wrap 으로 지킬 수 없다.
* 그래서 줄 앞 공백과, 공백 바로 뒤의 공백을 nbsp 로 바꾼다 — 보통 공백과 번갈아 두어 긴 공백에서도 줄이 바뀔 수 있다.
* 탭은 다음 탭 칸(8칸)까지의 공백으로 본다. 단어 사이 공백 한 칸은 그대로다.
* 문단 안의 글을 차례로 본다 — 서식(span · strong 등)의 경계를 넘어 이어진 공백도 하나로 본다. <br> 뒤는 새 줄이다.
* 텍스트 대체 본문(body)은 바꾸지 않는다 (docToText).
*/
function preserveSpaces(paragraph: Element, inert: Document) {
const walker = inert.createTreeWalker(paragraph, NodeFilter.SHOW_TEXT | NodeFilter.SHOW_ELEMENT)
let atLineStart = true
let afterSpace = false
let column = 0
for (let node = walker.nextNode(); node; node = walker.nextNode()) {
if (node.nodeType === Node.ELEMENT_NODE) {
if ((node as Element).tagName === 'BR') {
atLineStart = true
afterSpace = false
column = 0
}
continue
}
const text = node as Text
let out = ''
const space = () => {
// 줄 앞 · 공백 바로 뒤의 공백은 받는 쪽이 지우거나 합친다 — nbsp 는 줄지 않는다
const keep = atLineStart || afterSpace
out += keep ? NBSP : ' '
afterSpace = !keep
atLineStart = false
column += 1
}
for (const char of text.data) {
if (char === ' ') {
space()
} else if (char === '\t') {
const width = TAB_STOP - (column % TAB_STOP)
for (let step = 0; step < width; step += 1) space()
} else {
out += char
atLineStart = false
afterSpace = false
column += 1
}
}
if (out !== text.data) text.data = out
}
}
/** 끝에 남은 빈 문단(편집기가 늘 마지막에 두는 줄)은 보내지 않는다 */
function trimTrailingEmptyParagraphs(editorHtml: string): string {
return editorHtml.replace(/(?:<p(?: [^>]*)?><\/p>)+$/, '')
}
/** 보내는 서식 본문 — 기본 글꼴 · 글자 크기 · 줄 간격 div 로 감싼다 (계약 2절 "서식 본문", 편집기 화면과 같은 값) */
export function toMailHtml(editorHtml: string): string {
const inner = toMailHtmlFragment(trimTrailingEmptyParagraphs(editorHtml))
const wrapper = [
`font-family: ${MAIL_BODY_FONT_FAMILY}`,
`font-size: ${MAIL_BODY_FONT_SIZE_PX}px`,
`line-height: ${MAIL_BODY_LINE_HEIGHT}`,
].join('; ')
return `<div style="${wrapper}">${inner}</div>`
}
// ── 들어오는 HTML (원문 인용 · 붙여 넣기) ───────────────────────
/** 줄 단위 블록 — 이 안의 맨 끝 <br> 은 화면에 줄을 더하지 않는다 */
const LINE_BLOCKS = new Set([
'P',
'DIV',
'LI',
'TD',
'TH',
'BLOCKQUOTE',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'PRE',
'ADDRESS',
'ARTICLE',
'ASIDE',
'SECTION',
'HEADER',
'FOOTER',
'CENTER',
'DD',
'DT',
'BODY',
])
/** 글자가 없어도 내용이 있는 요소 — 이것이 있는 블록은 빈 줄이 아니다 */
const NON_TEXT_CONTENT = 'img, hr, table, iframe, video, audio, svg, canvas, object, embed, input'
function isSignificant(node: Node): boolean {
if (node.nodeType === Node.ELEMENT_NODE) return true
return node.nodeType === Node.TEXT_NODE && (node.textContent ?? '').trim() !== ''
}
function lineBlockOf(node: Node, root: Node): Element | null {
let current = node.parentNode
while (current && current !== root) {
if (current instanceof Element && LINE_BLOCKS.has(current.tagName)) return current
current = current.parentNode
}
return null
}
/** 블록 맨 끝의 <br> 인가 — 그 뒤로 블록 끝까지 글자·요소가 없다 */
function isTrailingBreak(br: Element, block: Node): boolean {
let node: Node | null = br
while (node && node !== block) {
for (let next = node.nextSibling; next; next = next.nextSibling) {
if (isSignificant(next)) return false
}
node = node.parentNode
}
return true
}
/**
* 블록 맨 끝의 <br> 을 편집기 모양으로 옮긴다.
* HTML 에서는 블록 끝의 <br> 이 줄을 더하지 않지만("<p>글<br></p>" 은 한 줄, "<p><br></p>" 는 빈 줄 하나),
* 편집기는 이것을 줄바꿈으로 읽어 빈 줄이 두 줄이 된다. 그래서
* - 글이 있는 블록의 끝 <br> 은 뺀다
* - <br> 만 있는 빈 블록은 빈 문단이 되게 한다 (p 는 <br> 만 빼고, div · li · 칸 등은 그 자리에 빈 <p>)
*/
function normalizeTrailingBreaks(root: DocumentFragment, inert: Document) {
const trailing = Array.from(root.querySelectorAll('br')).flatMap((br) => {
const block = lineBlockOf(br, root)
return isTrailingBreak(br, block ?? root) ? [{ br, block }] : []
})
for (const { br, block } of trailing) {
const isEmptyLine =
block !== null && (block.textContent ?? '').trim() === '' && block.querySelector(NON_TEXT_CONTENT) === null
if (isEmptyLine && block.tagName !== 'P') br.replaceWith(inert.createElement('p'))
else br.remove()
}
}
/**
* 편집기로 들어오는 HTML(원문 인용 · 붙여 넣기)을 편집기 모양에 맞춘다.
* 다른 사이트에서 복사한 HTML 이 그대로 들어온다 — 화면 문서로 옮기지 않고 떨어진 문서에서만 다룬다 (rewriteInert).
* - 블록 끝 <br> — 빈 줄이 두 줄로 늘지 않게 (normalizeTrailingBreaks)
* 서식 값(색 · 글꼴 · 글자 크기 — pt 는 px 로)은 편집기가 읽을 때 style 속성에서만 다룬다 (mail-format-extensions.ts).
* 여기서 HTML 문자열을 통째로 바꾸지 않는다 — 본문 글자 "font-size: 12pt" 까지 바뀐다.
*/
export function normalizeIncomingHtml(html: string): string {
return rewriteInert(html, normalizeTrailingBreaks)
}
@@ -0,0 +1,265 @@
// 툴바 단추 · 메뉴 항목 만들기 — 아이콘 단추 · 글꼴 · 글자 크기 · 더보기 (디자인 README "본문 서식 편집기", 원본 ComposeEditor "더보기 메뉴")
// 더보기: 좁은 폭에서 옮겨 온 묶음이 맨 위에 툴바 순서대로 오고, 그 아래에 늘 있는 서식이 온다.
// 취소선 | 왼쪽 · 가운데 · 오른쪽 정렬 | 내어쓰기 · 들여쓰기 | 인용 · 표 넣기 › · 가로줄 | 서식 지우기
import type { ChainedCommands } from '@tiptap/core'
import { menuSeparator, type EditorMenuItem } from '@/components/mail/editor/editor-menu'
import type { FontStatus, SizeStatus, ToolbarStatus } from '@/components/mail/editor/editor-state'
import type { IconName } from '@/shared/constants/icons'
import {
EDITOR_FONTS_EN,
EDITOR_FONTS_KO,
EDITOR_FONT_SIZES,
EDITOR_SHORTCUTS,
MAIL_BODY_FONT_SIZE_PX,
type EditorFont,
type EditorShortcut,
} from '@/shared/constants/mail-editor'
import type { ToolbarGroup } from '@/shared/utils/editor-toolbar-layout'
// ── 아이콘 단추 (툴바에 늘 보이는 것) ─────────────────────
export type IconToolId = 'undo' | 'redo' | 'bold' | 'italic' | 'underline' | 'bulletList' | 'orderedList'
/** 아이콘 하나짜리 단추 — 누르면 바로 서식을 바꾼다 */
export interface IconTool {
id: IconToolId
label: string
icon: IconName
shortcut: EditorShortcut
/** 켜고 끄는 서식이면 지금 상태 (아니면 null) */
pressed: boolean | null
disabled: boolean
run: (chain: ChainedCommands) => ChainedCommands
}
/** 실행 취소 · 다시 실행 | 굵게 · 기울임 · 밑줄 | 글머리 · 번호 목록 */
export function iconToolGroups(status: ToolbarStatus): Record<'history' | 'marks' | 'list', IconTool[]> {
const toggle = (id: IconToolId, label: string, icon: IconName, pressed: boolean, run: IconTool['run']): IconTool => ({
id,
label,
icon,
shortcut: EDITOR_SHORTCUTS[id],
pressed,
disabled: false,
run,
})
return {
history: [
{
id: 'undo',
label: '실행 취소',
icon: 'undo',
shortcut: EDITOR_SHORTCUTS.undo,
pressed: null,
disabled: !status.canUndo,
run: (chain) => chain.undo(),
},
{
id: 'redo',
label: '다시 실행',
icon: 'redo',
shortcut: EDITOR_SHORTCUTS.redo,
pressed: null,
disabled: !status.canRedo,
run: (chain) => chain.redo(),
},
],
marks: [
toggle('bold', '굵게', 'bold', status.bold, (chain) => chain.toggleBold()),
toggle('italic', '기울임', 'italic', status.italic, (chain) => chain.toggleItalic()),
toggle('underline', '밑줄', 'underline', status.underline, (chain) => chain.toggleUnderline()),
],
list: [
toggle('bulletList', '글머리 목록', 'list-bullet', status.bulletList, (chain) => chain.toggleBulletList()),
toggle('orderedList', '번호 목록', 'list-ordered', status.orderedList, (chain) => chain.toggleOrderedList()),
],
}
}
// ── 메뉴 ──────────────────────────────────────────────
/** 기본값 표시 — 글꼴 "맑은 고딕", 글자 크기 14 옆 */
const BASE_BADGE = '기본'
export function fontMenuItems(current: FontStatus): EditorMenuItem[] {
const entry = (font: EditorFont): EditorMenuItem => ({
id: font.id,
role: 'menuitemradio',
label: font.label,
preview: `font-${font.id}`,
checked: !current.mixed && current.font?.id === font.id,
...(font.isBase ? { badge: BASE_BADGE } : {}),
})
return [...EDITOR_FONTS_KO.map(entry), menuSeparator('font-latin'), ...EDITOR_FONTS_EN.map(entry)]
}
export function sizeMenuItems(current: SizeStatus): EditorMenuItem[] {
return EDITOR_FONT_SIZES.map((size) => ({
id: String(size),
role: 'menuitemradio',
label: String(size),
preview: `size-${size}`,
checked: !current.mixed && current.px === size,
...(size === MAIL_BODY_FONT_SIZE_PX ? { badge: BASE_BADGE } : {}),
}))
}
/** 더보기에서 한 단계 더 여는 항목 — 그 팝오버를 더보기 단추 아래에 연다 */
export const MORE_SUBMENUS = ['font', 'size', 'textColor', 'backgroundColor', 'link', 'table'] as const
export type MoreSubmenu = (typeof MORE_SUBMENUS)[number]
export function isMoreSubmenu(id: string): id is MoreSubmenu {
return (MORE_SUBMENUS as readonly string[]).includes(id)
}
/** 더보기에서 바로 실행하는 서식 */
export const MORE_COMMANDS: Readonly<Record<string, (chain: ChainedCommands) => ChainedCommands>> = {
undo: (chain) => chain.undo(),
redo: (chain) => chain.redo(),
bulletList: (chain) => chain.toggleBulletList(),
orderedList: (chain) => chain.toggleOrderedList(),
strike: (chain) => chain.toggleStrike(),
// 왼쪽은 기본 정렬이라 정렬 지정을 지운다
alignLeft: (chain) => chain.unsetTextAlign(),
alignCenter: (chain) => chain.setTextAlign('center'),
alignRight: (chain) => chain.setTextAlign('right'),
outdent: (chain) => chain.outdentBlock(),
indent: (chain) => chain.indentBlock(),
blockquote: (chain) => chain.toggleBlockquote(),
horizontalRule: (chain) => chain.setHorizontalRule(),
clearFormatting: (chain) => chain.clearFormatting(),
}
/** 좁은 폭에서 더보기로 옮겨 온 묶음의 항목 — 툴바 순서대로 */
function movedItems(status: ToolbarStatus, hidden: ReadonlySet<ToolbarGroup>): EditorMenuItem[] {
const items: EditorMenuItem[] = []
if (hidden.has('history')) {
items.push(
{ id: 'undo', label: '실행 취소', icon: 'undo', hint: EDITOR_SHORTCUTS.undo.label, disabled: !status.canUndo },
{ id: 'redo', label: '다시 실행', icon: 'redo', hint: EDITOR_SHORTCUTS.redo.label, disabled: !status.canRedo },
menuSeparator('after-history'),
)
}
if (hidden.has('font')) {
items.push(
{ id: 'font', label: '글꼴', icon: 'type', hint: status.font.label, opens: 'menu' },
{ id: 'size', label: '글자 크기', icon: 'font-size', hint: status.size.label, opens: 'menu' },
menuSeparator('after-font'),
)
}
if (hidden.has('color')) {
items.push(
{
id: 'textColor',
label: '글자 색',
colorGlyph: { kind: 'text', color: status.textColor.value },
hint: status.textColor.name,
opens: 'dialog',
},
{
id: 'backgroundColor',
label: '배경 색',
colorGlyph: { kind: 'background', color: status.backgroundColor.value },
hint: status.backgroundColor.name,
opens: 'dialog',
},
menuSeparator('after-color'),
)
}
if (hidden.has('list')) {
items.push(
{
id: 'bulletList',
role: 'menuitemcheckbox',
label: '글머리 목록',
icon: 'list-bullet',
hint: EDITOR_SHORTCUTS.bulletList.label,
checked: status.bulletList,
},
{
id: 'orderedList',
role: 'menuitemcheckbox',
label: '번호 목록',
icon: 'list-ordered',
hint: EDITOR_SHORTCUTS.orderedList.label,
checked: status.orderedList,
},
menuSeparator('after-list'),
)
}
if (hidden.has('link')) {
items.push(
{
id: 'link',
label: status.link ? '링크 고치기' : '링크 넣기',
icon: 'link',
hint: EDITOR_SHORTCUTS.link.label,
opens: 'dialog',
},
menuSeparator('after-link'),
)
}
return items
}
export function moreMenuItems(status: ToolbarStatus, hidden: ReadonlySet<ToolbarGroup>): EditorMenuItem[] {
return [
...movedItems(status, hidden),
{
id: 'strike',
role: 'menuitemcheckbox',
label: '취소선',
icon: 'strikethrough',
hint: EDITOR_SHORTCUTS.strike.label,
checked: status.strike,
},
menuSeparator('after-strike'),
{
id: 'alignLeft',
role: 'menuitemradio',
label: '왼쪽 정렬',
icon: 'align-left',
hint: EDITOR_SHORTCUTS.alignLeft.label,
checked: status.align === 'left',
},
{
id: 'alignCenter',
role: 'menuitemradio',
label: '가운데 정렬',
icon: 'align-center',
hint: EDITOR_SHORTCUTS.alignCenter.label,
checked: status.align === 'center',
},
{
id: 'alignRight',
role: 'menuitemradio',
label: '오른쪽 정렬',
icon: 'align-right',
hint: EDITOR_SHORTCUTS.alignRight.label,
checked: status.align === 'right',
},
menuSeparator('after-align'),
{ id: 'outdent', label: '내어쓰기', icon: 'outdent', hint: EDITOR_SHORTCUTS.outdent.label },
{ id: 'indent', label: '들여쓰기', icon: 'indent', hint: EDITOR_SHORTCUTS.indent.label },
menuSeparator('after-indent'),
{
id: 'blockquote',
role: 'menuitemcheckbox',
label: '인용',
icon: 'quote',
hint: EDITOR_SHORTCUTS.blockquote.label,
checked: status.blockquote,
},
// 표 안에는 표를 넣지 않는다
{ id: 'table', label: '표 넣기', icon: 'table', opens: 'dialog', disabled: status.inTable },
{ id: 'horizontalRule', label: '가로줄', icon: 'horizontal-rule' },
menuSeparator('after-insert'),
{
id: 'clearFormatting',
label: '서식 지우기',
icon: 'clear-format',
hint: EDITOR_SHORTCUTS.clearFormatting.label,
},
]
}
@@ -0,0 +1,75 @@
// 툴바 좁은 폭 처리 — 툴바의 실제 폭을 재어(ResizeObserver) 정해진 순서로 묶음째 더보기에 옮긴다
// (디자인 README "좁은 폭" — 고정 px 이 아니라 잰 폭이라 글자 크기 설정이 달라도 같은 순서로 접힌다)
// 묶음 폭은 [data-group] 요소, 더보기 단추는 [data-tool="more"] 로 잰다. 순서 · 계산은 shared/utils/editor-toolbar-layout.ts
import { onBeforeUnmount, onMounted, ref, type Ref } from 'vue'
import { REM_BASE_PX } from '@/shared/constants/icons'
import {
TOOLBAR_GROUPS,
collapsedToolbarGroups,
type ToolbarGroup,
} from '@/shared/utils/editor-toolbar-layout'
/** 폭을 잴 때 반올림 차이로 접히지 않게 두는 여유(px) */
const LAYOUT_TOLERANCE_PX = 0.5
function rootFontPx(): number {
return Number.parseFloat(getComputedStyle(document.documentElement).fontSize) || REM_BASE_PX
}
function sameGroups(a: ReadonlySet<ToolbarGroup>, b: ReadonlySet<ToolbarGroup>): boolean {
return a.size === b.size && [...a].every((group) => b.has(group))
}
export function useToolbarCollapse(rootRef: Ref<HTMLElement | null>) {
/** 더보기로 옮긴 묶음 */
const hidden = ref<ReadonlySet<ToolbarGroup>>(new Set())
/** 묶음 폭(rem) — 숨은 묶음은 마지막으로 잰 값을 쓴다. 글자 크기 설정이 바뀌어도 맞도록 rem 으로 둔다 */
const widthRem: Partial<Record<ToolbarGroup | 'more', number>> = {}
let observer: ResizeObserver | null = null
function measureWidth(key: ToolbarGroup | 'more', element: Element | null | undefined, remPx: number) {
const width = element?.getBoundingClientRect().width ?? 0
if (width > 0) widthRem[key] = width / remPx
}
function measure() {
const root = rootRef.value
if (!root) return
const remPx = rootFontPx()
const style = getComputedStyle(root)
const padding = (Number.parseFloat(style.paddingLeft) || 0) + (Number.parseFloat(style.paddingRight) || 0)
const available = root.clientWidth - padding
// 화면에 그려지지 않았다(숨김 등) — 잴 수 없으니 그대로 둔다
if (!(available > 0)) return
for (const group of TOOLBAR_GROUPS) {
if (!hidden.value.has(group)) measureWidth(group, root.querySelector(`[data-group="${group}"]`), remPx)
}
measureWidth('more', root.querySelector('[data-tool="more"]'), remPx)
const groups = Object.fromEntries(
TOOLBAR_GROUPS.map((group) => [group, (widthRem[group] ?? 0) * remPx]),
) as Record<ToolbarGroup, number>
const next = collapsedToolbarGroups({
available: available + LAYOUT_TOLERANCE_PX,
groups,
more: (widthRem.more ?? 0) * remPx,
gap: Number.parseFloat(style.columnGap) || 0,
})
if (!sameGroups(next, hidden.value)) hidden.value = next
}
onMounted(() => {
measure()
if (typeof ResizeObserver === 'undefined' || !rootRef.value) return
observer = new ResizeObserver(() => measure())
observer.observe(rootRef.value)
})
onBeforeUnmount(() => {
observer?.disconnect()
observer = null
})
return { hidden }
}
@@ -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 }
}
@@ -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')
})
})
+305 -10
View File
@@ -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}$/
+185 -51
View File
@@ -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 {
+74
View File
@@ -34,7 +34,15 @@ const rect = (x: number, y: number, width: number, height: number, radius = 0):
},
})
/** 채운 점 — 목록 글머리 · 더보기 (선이 아니라 면으로 그린다) */
const dot = (cx: number, cy: number, r: number): IconShape => ({
tag: 'circle',
attrs: { cx: String(cx), cy: String(cy), r: String(r), fill: 'currentColor', stroke: 'none' },
})
const ALERT_DOT = [line(12, 8, 12, 12), line(12, 16, 12.01, 16)]
/** 정렬·들여쓰기 아이콘의 오른쪽 세 줄 */
const INDENT_LINES = [line(21, 12, 11, 12), line(21, 6, 11, 6), line(21, 18, 11, 18)]
export const ICONS = {
mail: [
@@ -131,6 +139,72 @@ export const ICONS = {
'arrow-left': [line(19, 12, 5, 12), polyline('12 19 5 12 12 5')],
'chevron-left': [polyline('15 18 9 12 15 6')],
'chevron-right': [polyline('9 18 15 12 9 6')],
'chevron-down': [polyline('6 9 12 15 18 9')],
// ── 서식 편집기 툴바 (디자인 원본 Compose · ComposeEditor) ──
undo: [path('M9 14 4 9l5-5'), path('M4 9h10.5a5.5 5.5 0 0 1 0 11H11')],
redo: [path('m15 14 5-5-5-5'), path('M20 9H9.5a5.5 5.5 0 0 0 0 11H13')],
bold: [path('M6 4h8a4 4 0 0 1 0 8H6z'), path('M6 12h9a4 4 0 0 1 0 8H6z')],
italic: [line(19, 4, 10, 4), line(14, 20, 5, 20), line(15, 4, 9, 20)],
underline: [path('M6 3v7a6 6 0 0 0 12 0V3'), line(4, 21, 20, 21)],
strikethrough: [path('M16 4H9a3 3 0 0 0-2.83 4'), path('M14 12a4 4 0 0 1 0 8H6'), line(4, 12, 20, 12)],
'list-bullet': [
line(9, 6, 20, 6),
line(9, 12, 20, 12),
line(9, 18, 20, 18),
dot(4.5, 6, 1.2),
dot(4.5, 12, 1.2),
dot(4.5, 18, 1.2),
],
'list-ordered': [
line(10, 6, 21, 6),
line(10, 12, 21, 12),
line(10, 18, 21, 18),
path('M4 6h1v4'),
path('M4 10h2'),
path('M6 18H4c0-1 2-2 2-3s-1-1.5-2-1'),
],
link: [
path('M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'),
path('M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'),
],
unlink: [
path('m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71'),
path('m5.17 11.75-1.71 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71'),
line(8, 2, 8, 5),
line(2, 8, 5, 8),
line(16, 19, 16, 22),
line(19, 16, 22, 16),
],
'more-horizontal': [dot(5, 12, 1.6), dot(12, 12, 1.6), dot(19, 12, 1.6)],
'align-left': [line(17, 10, 3, 10), line(21, 6, 3, 6), line(21, 14, 3, 14), line(17, 18, 3, 18)],
'align-center': [line(18, 10, 6, 10), line(21, 6, 3, 6), line(21, 14, 3, 14), line(18, 18, 6, 18)],
'align-right': [line(21, 10, 7, 10), line(21, 6, 3, 6), line(21, 14, 3, 14), line(21, 18, 7, 18)],
outdent: [polyline('7 8 3 12 7 16'), ...INDENT_LINES],
indent: [polyline('3 8 7 12 3 16'), ...INDENT_LINES],
quote: [path('M17 6H3'), path('M21 12H8'), path('M21 18H8'), path('M3 12v6')],
table: [rect(3, 3, 18, 18, 2), line(3, 9, 21, 9), line(3, 15, 21, 15), line(9, 3, 9, 21), line(15, 3, 15, 21)],
'horizontal-rule': [line(3, 12, 21, 12), polyline('8 8 12 4 16 8'), polyline('16 16 12 20 8 16')],
'clear-format': [
path('M4 7V4h16v3'),
path('M5 20h6'),
path('M13 4 8 20'),
path('m15 15 5 5'),
path('m20 15-5 5'),
],
/** 더보기 메뉴의 글꼴 */
type: [polyline('4 7 4 4 20 4 20 7'), line(9, 20, 15, 20), line(12, 4, 12, 20)],
/** 더보기 메뉴의 글자 크기 */
'font-size': [
path('M3 7V5h10v2'),
path('M8 5v14'),
path('M6 19h4'),
path('M14 12v-1h7v1'),
path('M17.5 11v8'),
path('M16 19h3'),
],
/** 배경 색 "없음" 견본의 빗금 */
slash: [line(5, 19, 19, 5)],
} as const satisfies Record<string, readonly IconShape[]>
export type IconName = keyof typeof ICONS
@@ -0,0 +1,179 @@
// 메일 쓰기 서식 편집기 상수 — 디자인 원본 Compose · ComposeEditor (docs/design/webmail README "메일 쓰기")
// 보내는 HTML 에 그대로 들어가는 값(글꼴 스택·색)은 여기 한 곳에서만 정한다. 화면 CSS 는 tokens.css 의 같은 값을 쓴다.
import {
currentPlatform,
formatShortcut,
isApplePlatform,
type ShortcutKeys,
type ShortcutText,
} from '@/shared/utils/keyboard-shortcut'
/** 화면에 하나뿐인 본문 편집 영역의 id — 툴바의 aria-controls 가 가리킨다 */
export const MAIL_EDITOR_BODY_ID = 'compose-body'
/**
* 본문 기본 글꼴 — 맑은 고딕 스택 (사용자 확정 2026-10-27). 편집기 안 모양을 받는 사람이 보는 모양과 같게 한다.
* 보낼 때 본문 전체를 이 글꼴·14px 의 div 하나로 감싼다 (계약 2절 "서식 본문"). tokens.css 의 --font-family-mail-body 와 같은 값.
*/
export const MAIL_BODY_FONT_FAMILY = "'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif"
/** 본문 기본 글자 크기(px) */
export const MAIL_BODY_FONT_SIZE_PX = 14
/**
* 본문 줄 간격 (단위 없는 배수) — 감싸는 div 에 함께 넣어 받는 쪽도 편집기 화면과 같은 줄 간격으로 본다.
* tokens.css 의 --line-height-mail-body 와 같은 값 (편집 영역이 그 변수를 쓴다 — 바꾸면 둘을 함께 바꾼다).
*/
export const MAIL_BODY_LINE_HEIGHT = 1.75
/** 서식 본문(bodyHtml) 길이 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
export const MAIL_BODY_HTML_MAX_LENGTH = 500_000
/** 빈 본문 안내 (디자인 원본 ComposeEditor "빈 본문") */
export const EDITOR_PLACEHOLDER = '내용을 입력하세요'
// ── 글꼴 · 글자 크기 ─────────────────────────────────────
export interface EditorFont {
id: string
label: string
/** font-family 값 — 기본 글꼴은 지정을 지우므로 화면의 목록 미리보기에만 쓴다 */
family: string
/** 첫 항목 — 고르면 글꼴 지정을 지운다 (감싸는 div 의 기본 글꼴을 따른다) */
isBase?: boolean
}
/** 받는 쪽에 흔히 있는 글꼴만 (디자인 README "글꼴") — 한글 4 | 영문 5 */
export const EDITOR_FONTS_KO: readonly EditorFont[] = [
{ id: 'malgun', label: '맑은 고딕', family: MAIL_BODY_FONT_FAMILY, isBase: true },
{ id: 'gulim', label: '굴림', family: "Gulim, '굴림', sans-serif" },
{ id: 'dotum', label: '돋움', family: "Dotum, '돋움', sans-serif" },
{ id: 'batang', label: '바탕', family: "Batang, '바탕', serif" },
]
export const EDITOR_FONTS_EN: readonly EditorFont[] = [
{ id: 'arial', label: 'Arial', family: 'Arial, sans-serif' },
{ id: 'verdana', label: 'Verdana', family: 'Verdana, sans-serif' },
{ id: 'georgia', label: 'Georgia', family: 'Georgia, serif' },
{ id: 'times', label: 'Times New Roman', family: "'Times New Roman', serif" },
{ id: 'courier', label: 'Courier New', family: "'Courier New', monospace" },
]
export const EDITOR_FONTS: readonly EditorFont[] = [...EDITOR_FONTS_KO, ...EDITOR_FONTS_EN]
/** 글자 크기(px) — 제목 스타일은 두지 않는다. 14 가 기본(고르면 크기 지정을 지운다) */
export const EDITOR_FONT_SIZES: readonly number[] = [12, 13, 14, 16, 18, 20, 24, 32]
/** 고른 글자의 글꼴·크기가 섞였을 때 보이는 표시 */
export const EDITOR_MIXED_VALUE = '–'
// ── 색 (8색씩 — 디자인 README "색 팔레트") ─────────────────
export interface EditorColor {
id: string
name: string
/** null 이면 지정을 지운다 (글자 색 "기본" · 배경 색 "없음") */
value: string | null
/** 견본에 보이는 색 */
swatch: string
/** 견본 위 ✓ 색 — 어두운 견본에는 밝은 ✓ */
checkTone: 'light' | 'dark'
/** 지우는 견본("없음")에 빗금을 긋는다 */
slashed?: boolean
}
/** 글자 색 — 모두 흰 바탕 4.5:1 이상. "기본"은 글자 색을 지운다 */
export const EDITOR_TEXT_COLORS: readonly EditorColor[] = [
{ id: 'default', name: '기본', value: null, swatch: '#1F2733', checkTone: 'light' },
{ id: 'gray', name: '회색', value: '#667085', swatch: '#667085', checkTone: 'light' },
{ id: 'red', name: '빨강', value: '#B42318', swatch: '#B42318', checkTone: 'light' },
{ id: 'orange', name: '주황', value: '#B54708', swatch: '#B54708', checkTone: 'light' },
{ id: 'green', name: '초록', value: '#067647', swatch: '#067647', checkTone: 'light' },
{ id: 'teal', name: '청록', value: '#0E7C86', swatch: '#0E7C86', checkTone: 'light' },
{ id: 'blue', name: '파랑', value: '#175CD3', swatch: '#175CD3', checkTone: 'light' },
{ id: 'purple', name: '보라', value: '#6941C6', swatch: '#6941C6', checkTone: 'light' },
]
/** 배경 색 — 본문 글자(#1F2733)가 10:1 이상 남는 옅은 색. "없음"은 배경 색을 지운다 */
export const EDITOR_BACKGROUND_COLORS: readonly EditorColor[] = [
{ id: 'none', name: '없음', value: null, swatch: '#FFFFFF', checkTone: 'dark', slashed: true },
{ id: 'yellow', name: '노랑', value: '#FFF1A8', swatch: '#FFF1A8', checkTone: 'dark' },
{ id: 'orange', name: '주황', value: '#FDE4CC', swatch: '#FDE4CC', checkTone: 'dark' },
{ id: 'pink', name: '분홍', value: '#FBDCE3', swatch: '#FBDCE3', checkTone: 'dark' },
{ id: 'green', name: '초록', value: '#D9F2DF', swatch: '#D9F2DF', checkTone: 'dark' },
{ id: 'sky', name: '하늘', value: '#D6E8FB', swatch: '#D6E8FB', checkTone: 'dark' },
{ id: 'purple', name: '보라', value: '#E9E0FA', swatch: '#E9E0FA', checkTone: 'dark' },
{ id: 'gray', name: '회색', value: '#E9EBEF', swatch: '#E9EBEF', checkTone: 'dark' },
]
/** 팔레트 밖의 색(붙여 넣은 글 등)일 때 이름 */
export const EDITOR_OTHER_COLOR_NAME = '다른 색'
// ── 들여쓰기 · 표 ─────────────────────────────────────
/** 들여쓰기 한 단계(px) — 보낼 때 문단의 margin-left 가 된다 (서버 정화 허용 목록) */
export const EDITOR_INDENT_STEP_PX = 40
export const EDITOR_MAX_INDENT = 8
/** 표 넣기 격자 — 8열 × 6행 (디자인 원본 ComposeEditor "표 넣기") */
export const TABLE_PICKER_COLUMNS = 8
export const TABLE_PICKER_ROWS = 6
/** 처음 열었을 때 골라 둔 크기 */
export const TABLE_PICKER_DEFAULT = { rows: 3, cols: 4 } as const
// ── 단축키 (디자인 README "단축키") ─────────────────────
/** 화면에 보이는 글자(label)와 aria-keyshortcuts 값(aria) */
export type EditorShortcut = ShortcutText
interface ShortcutDefinition {
/** 키 이름 — 'Mod' 는 맥에서 ⌘, 그 밖에서는 Ctrl (TipTap 의 Mod 와 같다) */
keys: ShortcutKeys
/** 맥에서만 다른 키 — 다시 실행은 맥의 관례대로 ⇧⌘Z (편집기는 ⌘Y 도 받는다) */
appleKeys?: ShortcutKeys
}
const SHORTCUT_DEFINITIONS = {
undo: { keys: ['Mod', 'Z'] },
redo: { keys: ['Mod', 'Y'], appleKeys: ['Mod', 'Shift', 'Z'] },
bold: { keys: ['Mod', 'B'] },
italic: { keys: ['Mod', 'I'] },
underline: { keys: ['Mod', 'U'] },
strike: { keys: ['Mod', 'Shift', 'S'] },
bulletList: { keys: ['Mod', 'Shift', '8'] },
orderedList: { keys: ['Mod', 'Shift', '7'] },
alignLeft: { keys: ['Mod', 'Shift', 'L'] },
alignCenter: { keys: ['Mod', 'Shift', 'E'] },
alignRight: { keys: ['Mod', 'Shift', 'R'] },
outdent: { keys: ['Mod', '['] },
indent: { keys: ['Mod', ']'] },
blockquote: { keys: ['Mod', 'Shift', 'B'] },
link: { keys: ['Mod', 'K'] },
clearFormatting: { keys: ['Mod', '\\'] },
} as const satisfies Record<string, ShortcutDefinition>
export type EditorShortcutName = keyof typeof SHORTCUT_DEFINITIONS
/** 플랫폼에 맞는 단축키 표기 — 윈도우는 "Ctrl+B", 맥은 "⌘B" */
export function buildEditorShortcuts(apple: boolean): Record<EditorShortcutName, EditorShortcut> {
const entries = Object.entries(SHORTCUT_DEFINITIONS) as [EditorShortcutName, ShortcutDefinition][]
return Object.fromEntries(
entries.map(([name, definition]) => [
name,
formatShortcut(apple && definition.appleKeys ? definition.appleKeys : definition.keys, apple),
]),
) as Record<EditorShortcutName, EditorShortcut>
}
/** 지금 브라우저의 단축키 표기 */
export const EDITOR_SHORTCUTS = buildEditorShortcuts(isApplePlatform(currentPlatform()))
// ── 본문 이미지 → 첨부 (디자인 README "본문 이미지 → 첨부 파일") ──────────
/** 붙여 넣은 이미지 이름 앞부분 — "붙여넣은 이미지 1.png" */
export const PASTED_IMAGE_NAME_PREFIX = '붙여넣은 이미지'
/** 이미지를 첨부로 돌렸을 때만 띄우는 성공 알림 */
export const IMAGE_AS_ATTACHMENT_MESSAGE = '이미지는 본문 대신 첨부 파일로 올렸습니다.'
/** 끌어다 놓는 동안 본문 위 덮개 글 */
export const DROP_TO_ATTACH_LABEL = '놓으면 첨부 파일로 올립니다'
// ── 원문 인용 (디자인 README "답장 · 전달의 원문 인용") ──────────────
/** 인용 머리 — 답장 · 전달 모두 */
export const MAIL_QUOTE_HEADING = '원본 메일'
+1 -5
View File
@@ -110,11 +110,7 @@ export const MAIL_IDEMPOTENCY_HEADER = 'Idempotency-Key'
// ── 답장 · 전달 ─────────────────────────────────────────
export const MAIL_REPLY_PREFIX = 'Re: '
export const MAIL_FORWARD_PREFIX = 'Fwd: '
/** 답장·전달 본문에서 내 글과 원문을 나누는 선 (디자인 원본 Compose) */
export const MAIL_QUOTE_SEPARATOR = '──────────'
/** 전달 본문의 원문 머리 제목 */
export const MAIL_FORWARD_HEADING = '전달된 메일'
/** 답장 인용 줄 머리 */
/** 텍스트 본문(body)의 인용 줄 머리 — 서식 본문의 인용 막대에 해당한다 (계약 2절 "서식 본문") */
export const MAIL_QUOTE_LINE_PREFIX = '> '
/**
* 답장·전달 초안의 인용(원문)을 본문 한도에 맞춰 줄였을 때 끝에 두는 표시 (계약 2절 "답장·전달 초안의 길이")
+3 -1
View File
@@ -100,8 +100,10 @@ export interface SendMessagePayload {
cc: Address[]
bcc: Address[]
subject: string
/** 일반 텍스트 */
/** 일반 텍스트 — 서식 본문을 보낼 때는 같은 내용의 대체 본문 (목록 "- "·"1. ", 인용 "> ") */
body: string
/** 서식 본문 (계약 2절 "서식 본문") — 기본 글꼴 div 로 감싼 편집기 HTML. 없으면 텍스트 메일 */
bodyHtml?: string
attachments: AttachmentRef[]
/** 답장할 메일의 id — 스레드 헤더는 서버가 채운다 */
inReplyTo?: string
@@ -0,0 +1,79 @@
import { describe, expect, it } from 'vitest'
import { isFileDrag, isImageFile, pastedFiles, pastedImageName, renameFile } from './editor-files'
function clipboard(files: File[], text = '', html = ''): DataTransfer {
const values: Record<string, string> = { 'text/plain': text, 'text/html': html }
return {
files,
getData: (type: string) => values[type] ?? '',
} as unknown as DataTransfer
}
const png = (name = 'image.png') => new File(['png'], name, { type: 'image/png' })
describe('pastedFiles — 붙여 넣기에서 첨부로 돌릴 파일', () => {
it('글 없이 파일만 있으면 그 파일들', () => {
const image = png()
expect(pastedFiles(clipboard([image]))).toEqual([image])
})
it('글이 함께 오면(워드·엑셀 복사) 글을 붙여 넣는다 — 첨부로 돌리지 않는다', () => {
expect(pastedFiles(clipboard([png()], '표 내용'))).toEqual([])
})
it('워드 · 엑셀처럼 HTML 에 글이 있으면 글을 붙여 넣는다', () => {
expect(pastedFiles(clipboard([png()], '표 내용', '<table><tr><td>표 내용</td></tr></table>'))).toEqual([])
})
it('브라우저의 "이미지 복사" — HTML 이 그림(<img>)뿐이거나 글이 그림 주소뿐이면 글이 없는 것으로 보고 그림을 첨부로', () => {
const image = png()
const address = 'https://img.example/photo.png'
expect(pastedFiles(clipboard([image], '', `<html><body><!--StartFragment--><img src="${address}"><!--EndFragment--></body></html>`))).toEqual([image])
expect(pastedFiles(clipboard([image], address))).toEqual([image])
expect(pastedFiles(clipboard([image], address, `<img src="${address}" alt="사진">`))).toEqual([image])
expect(pastedFiles(clipboard([image], 'data:image/png;base64,iVBORw0KGgo='))).toEqual([image])
})
it('그림 주소 말고 다른 글이 함께 있으면 글을 붙여 넣는다', () => {
expect(pastedFiles(clipboard([png()], 'https://img.example/a.png 사진 설명'))).toEqual([])
expect(pastedFiles(clipboard([png()], '', '<p>사진 설명</p><img src="https://img.example/a.png">'))).toEqual([])
})
it('파일이 없거나 클립보드가 없으면 빈 목록', () => {
expect(pastedFiles(clipboard([]))).toEqual([])
expect(pastedFiles(null)).toEqual([])
})
})
describe('pastedImageName — "붙여넣은 이미지 N.확장자"', () => {
it('브라우저가 붙인 이름(image.png)이나 이름 없는 그림이면 바꾼다 — 확장자는 형식에서', () => {
expect(pastedImageName(png(), 1)).toBe('붙여넣은 이미지 1.png')
expect(pastedImageName(new File(['j'], '', { type: 'image/jpeg' }), 2)).toBe('붙여넣은 이미지 2.jpg')
expect(pastedImageName(new File(['w'], 'IMAGE.WEBP', { type: '' }), 3)).toBeNull()
})
it('원래 이름이 있는 파일 · 그림이 아닌 파일은 그대로 (null)', () => {
expect(pastedImageName(png('화면 캡처.png'), 1)).toBeNull()
expect(pastedImageName(new File(['t'], 'image.txt', { type: 'text/plain' }), 1)).toBeNull()
})
})
describe('renameFile · isImageFile · isFileDrag', () => {
it('이름만 바꾸고 형식은 그대로', () => {
const renamed = renameFile(png(), '붙여넣은 이미지 1.png')
expect(renamed.name).toBe('붙여넣은 이미지 1.png')
expect(renamed.type).toBe('image/png')
expect(isImageFile(renamed)).toBe(true)
expect(isImageFile(new File(['p'], 'a.pdf', { type: 'application/pdf' }))).toBe(false)
})
it('파일을 끌 때만 덮개를 보인다 — 본문 글을 옮기는 끌기는 아니다', () => {
const drag = (types: string[]) => ({ dataTransfer: { types } }) as unknown as DragEvent
expect(isFileDrag(drag(['Files']))).toBe(true)
expect(isFileDrag(drag(['text/html', 'text/plain']))).toBe(false)
})
})
+69
View File
@@ -0,0 +1,69 @@
// 본문에 붙여 넣거나 끌어다 놓은 파일 → 첨부 (디자인 README "본문 이미지 → 첨부 파일", 사용자 확정 2026-10-27)
// 본문에는 이미지를 넣지 않는다. 쓰기 화면(ComposePage)과 편집기가 함께 쓰므로 편집기(TipTap)에 기대지 않는다.
import { PASTED_IMAGE_NAME_PREFIX } from '@/shared/constants/mail-editor'
/** 브라우저가 클립보드 그림에 붙이는 이름 — 이런 이름이면 "붙여넣은 이미지 N" 으로 바꾼다 */
const GENERIC_IMAGE_NAME = /^image\.[a-z0-9]+$/i
/** 클립보드 그림 형식 → 확장자 (첨부 허용 형식 안의 그림만 — 계약 2절 "첨부 업로드 한도") */
const IMAGE_EXTENSIONS: Readonly<Record<string, string>> = {
'image/png': 'png',
'image/jpeg': 'jpg',
'image/gif': 'gif',
'image/webp': 'webp',
}
const DEFAULT_IMAGE_EXTENSION = 'png'
export function isImageFile(file: File): boolean {
return file.type.startsWith('image/')
}
/** 끌고 있는 것이 파일인가 — 본문 안에서 글을 옮기는 끌기에는 덮개를 보이지 않는다 */
export function isFileDrag(event: DragEvent): boolean {
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
}
/** 글 한 덩어리가 그림 주소뿐인가 — 브라우저의 "이미지 복사"가 그림과 함께 주소를 글로 넣는다 */
const IMAGE_ADDRESS_ONLY = /^(?:https?:\/\/|data:image\/|blob:|file:)\S*$/i
/**
* 클립보드에 붙여 넣을 글이 있는가.
* - HTML 이 있으면 그 글자로 본다 — 그림(<img>)뿐이면 글이 없다 (떨어진 문서에서 읽는다 — 그림을 내려받거나 처리기를 돌리지 않는다)
* - HTML 이 없으면 글(text/plain)로 본다 — 그림 주소 하나뿐이면 글이 없다
*/
function hasPastableText(data: DataTransfer): boolean {
const html = data.getData('text/html')
if (html !== '') {
const text = new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''
return text.trim() !== ''
}
const text = data.getData('text/plain').trim()
return text !== '' && !IMAGE_ADDRESS_ONLY.test(text)
}
/**
* 붙여 넣기에서 첨부로 돌릴 파일 — 클립보드에 글 없이 파일만 있을 때.
* 워드·엑셀에서 복사하면 글과 함께 고른 부분의 그림도 오는데, 그때는 글을 붙여 넣는다(그림은 편집기가 받지 않는다).
* 브라우저에서 "이미지 복사"를 하면 그림과 함께 <img> 뿐인 HTML 이나 그림 주소가 오는데, 붙여 넣을 글이 없으니 그림을 첨부로 돌린다.
*/
export function pastedFiles(data: DataTransfer | null): File[] {
if (!data || data.files.length === 0) return []
if (hasPastableText(data)) return []
return Array.from(data.files)
}
/** 클립보드 그림의 첨부 이름 — "붙여넣은 이미지 1.png". 원래 이름이 있는 파일이면 null(그대로 쓴다) */
export function pastedImageName(file: File, index: number): string | null {
if (!isImageFile(file)) return null
if (file.name !== '' && !GENERIC_IMAGE_NAME.test(file.name)) return null
const fromName = GENERIC_IMAGE_NAME.test(file.name) ? file.name.split('.').pop() : undefined
const extension = IMAGE_EXTENSIONS[file.type] ?? fromName ?? DEFAULT_IMAGE_EXTENSION
return `${PASTED_IMAGE_NAME_PREFIX} ${index}.${extension.toLowerCase()}`
}
/** 이름만 바꾼 같은 파일 */
export function renameFile(file: File, name: string): File {
return new File([file], name, { type: file.type, lastModified: file.lastModified })
}
@@ -0,0 +1,55 @@
import { describe, expect, it } from 'vitest'
import { collapsedToolbarGroups, type ToolbarMeasure } from './editor-toolbar-layout'
/**
* 디자인 원본의 묶음 폭(뒤 구분선 포함) — 실행 취소 93 · 글꼴 177 · 굵게 묶음 135 · 색 93 · 목록 93 · 링크 51, 더보기 40, 간격 2.
* 툴바 좌우 여백 20 씩을 뺀 폭이 available 이다.
*/
const DESIGN: Omit<ToolbarMeasure, 'available'> = {
groups: { history: 93, font: 177, marks: 135, color: 93, list: 93, link: 51 },
more: 40,
gap: 2,
}
const TOOLBAR_INLINE_PADDING = 40
function hiddenAt(toolbarWidth: number): string[] {
return [...collapsedToolbarGroups({ ...DESIGN, available: toolbarWidth - TOOLBAR_INLINE_PADDING })]
}
describe('collapsedToolbarGroups — 좁은 폭에서 묶음째 더보기로 (디자인 README "좁은 폭")', () => {
it('734px 이상이면 모두 보인다', () => {
expect(hiddenAt(800)).toEqual([])
expect(hiddenAt(734)).toEqual([])
})
it.each([
[733, ['history']],
[639, ['history']],
[638, ['history', 'font']],
[460, ['history', 'font']],
[459, ['history', 'font', 'color']],
[365, ['history', 'font', 'color']],
[364, ['history', 'font', 'color', 'link']],
[312, ['history', 'font', 'color', 'link']],
[311, ['history', 'font', 'color', 'link', 'list']],
])('%ipx — 숨는 묶음 %j (실행 취소 → 글꼴 → 색 → 링크 → 목록)', (width, expected) => {
expect(hiddenAt(width)).toEqual(expected)
})
it('굵게 · 기울임 · 밑줄과 더보기는 늘 남는다 — 아주 좁아도', () => {
expect(hiddenAt(120)).not.toContain('marks')
expect(hiddenAt(120)).toHaveLength(5)
})
it('고정 px 이 아니라 잰 폭으로 정한다 — 글자 크기 설정으로 단추가 커지면 더 일찍 접힌다', () => {
const larger = {
groups: { history: 116, font: 221, marks: 169, color: 116, list: 116, link: 64 },
more: 50,
gap: 2.5,
available: 700,
}
expect([...collapsedToolbarGroups(larger)]).toEqual(['history', 'font'])
})
})
@@ -0,0 +1,41 @@
// 편집기 툴바 좁은 폭 처리 — 순수 함수 (디자인 README "메일 쓰기 · 좁은 폭")
// 툴바는 줄을 바꾸지 않는다. 폭이 모자라면 정해진 순서로 묶음째 더보기에 옮긴다.
// 폭은 고정 px 이 아니라 화면에서 잰 값을 넘긴다 — 글자 크기 설정이 달라도 같은 순서로 접힌다.
/** 툴바 묶음 — 툴바 순서 */
export type ToolbarGroup = 'history' | 'font' | 'marks' | 'color' | 'list' | 'link'
export const TOOLBAR_GROUPS: readonly ToolbarGroup[] = ['history', 'font', 'marks', 'color', 'list', 'link']
/**
* 더보기로 옮기는 순서 — 실행 취소 · 다시 실행 → 글꼴 · 크기 → 색 → 링크 → 목록.
* 굵게 · 기울임 · 밑줄(marks)과 더보기 단추는 늘 남는다.
*/
export const TOOLBAR_COLLAPSE_ORDER: readonly ToolbarGroup[] = ['history', 'font', 'color', 'link', 'list']
export interface ToolbarMeasure {
/** 툴바 안쪽에서 버튼이 쓸 수 있는 폭 (좌우 여백 뺀 값) */
available: number
/** 묶음마다 잰 폭 (뒤의 구분선 포함) */
groups: Readonly<Record<ToolbarGroup, number>>
/** 더보기 단추 폭 */
more: number
/** 툴바 칸 사이 간격 */
gap: number
}
/** 폭이 모자라 더보기로 옮길 묶음 — 정해진 순서로 하나씩 옮기며 들어갈 때까지 */
export function collapsedToolbarGroups(measure: ToolbarMeasure): Set<ToolbarGroup> {
const hidden = new Set<ToolbarGroup>()
const required = () => {
const visible = TOOLBAR_GROUPS.filter((group) => !hidden.has(group))
const itemCount = visible.length + 1
const widths = visible.reduce((sum, group) => sum + measure.groups[group], 0)
return widths + measure.more + measure.gap * (itemCount - 1)
}
for (const group of TOOLBAR_COLLAPSE_ORDER) {
if (required() <= measure.available) break
hidden.add(group)
}
return hidden
}
@@ -0,0 +1,46 @@
import { describe, expect, it } from 'vitest'
import { buildEditorShortcuts } from '@/shared/constants/mail-editor'
import { formatShortcut, isApplePlatform } from './keyboard-shortcut'
describe('isApplePlatform — TipTap(prosemirror-keymap)의 Mod 판정과 같다', () => {
it.each(['MacIntel', 'iPhone', 'iPad', 'iPod'])('맥 · 아이폰 · 아이패드: %s', (platform) => {
expect(isApplePlatform(platform)).toBe(true)
})
it.each(['Win32', 'Linux x86_64', ''])('그 밖: "%s"', (platform) => {
expect(isApplePlatform(platform)).toBe(false)
})
})
describe('formatShortcut — 표기와 aria-keyshortcuts', () => {
it('윈도우 · 리눅스는 Ctrl', () => {
expect(formatShortcut(['Mod', 'Shift', 'S'], false)).toEqual({ label: 'Ctrl+Shift+S', aria: 'Control+Shift+S' })
expect(formatShortcut(['Mod', '['], false)).toEqual({ label: 'Ctrl+[', aria: 'Control+[' })
})
it('맥은 ⌘ — 수정 글쇠는 애플 순서(⌥ ⇧ ⌘)로 앞에, aria 는 Meta', () => {
expect(formatShortcut(['Mod', 'Shift', 'S'], true)).toEqual({ label: '⇧⌘S', aria: 'Meta+Shift+S' })
expect(formatShortcut(['Mod', 'B'], true)).toEqual({ label: '⌘B', aria: 'Meta+B' })
expect(formatShortcut(['Mod', '\\'], true)).toEqual({ label: '⌘\\', aria: 'Meta+\\' })
})
})
describe('buildEditorShortcuts — 편집기 단축키 (디자인 README "단축키")', () => {
it('윈도우는 디자인 표기 그대로', () => {
const shortcuts = buildEditorShortcuts(false)
expect(shortcuts.bold).toEqual({ label: 'Ctrl+B', aria: 'Control+B' })
expect(shortcuts.redo).toEqual({ label: 'Ctrl+Y', aria: 'Control+Y' })
expect(shortcuts.outdent.label).toBe('Ctrl+[')
})
it('맥은 ⌘ — 다시 실행은 맥의 관례대로 ⇧⌘Z (편집기는 ⌘Y 도 받는다)', () => {
const shortcuts = buildEditorShortcuts(true)
expect(shortcuts.bold).toEqual({ label: '⌘B', aria: 'Meta+B' })
expect(shortcuts.redo).toEqual({ label: '⇧⌘Z', aria: 'Meta+Shift+Z' })
expect(shortcuts.indent.label).toBe('⌘]')
expect(Object.values(shortcuts).every((shortcut) => !shortcut.label.includes('Ctrl'))).toBe(true)
})
})
@@ -0,0 +1,47 @@
// 단축키 표기 — 순수 함수 (디자인 README "본문 서식 편집기 · 단축키")
// TipTap 의 'Mod' 는 맥 · 아이폰 · 아이패드에서 ⌘(Command), 그 밖에서는 Ctrl 이다.
// 판정은 TipTap 이 쓰는 prosemirror-keymap 과 같은 기준(navigator.platform)으로 한다 — 보이는 표기와 실제로 먹는 키가 어긋나지 않게.
/** 단축키 한 개 — 화면에 보이는 글자와 aria-keyshortcuts 값 */
export interface ShortcutText {
label: string
aria: string
}
/** 키 이름 — 'Mod' · 'Shift' · 'Alt' 와 마지막 글쇠 ('B' · '[' · '\\' 등) */
export type ShortcutKeys = readonly string[]
/** prosemirror-keymap 의 맥 판정과 같다 */
export function isApplePlatform(platform: string): boolean {
return /Mac|iP(hone|[oa]d)/.test(platform)
}
/** 지금 브라우저의 플랫폼 이름 — 브라우저가 아니면 빈 문자열 */
export function currentPlatform(): string {
return typeof navigator === 'undefined' ? '' : navigator.platform
}
/** 맥 표기의 수정 글쇠 — 애플 순서(⌃ ⌥ ⇧ ⌘)로 앞에 붙인다 */
const APPLE_MODIFIERS: readonly (readonly [key: string, symbol: string])[] = [
['Alt', '⌥'],
['Shift', '⇧'],
['Mod', '⌘'],
]
/** 그 밖의 플랫폼 표기 · aria 이름 */
const LABEL_NAMES: Readonly<Record<string, string>> = { Mod: 'Ctrl' }
const ARIA_NAMES: Readonly<Record<string, string>> = { Mod: 'Control' }
const APPLE_ARIA_NAMES: Readonly<Record<string, string>> = { Mod: 'Meta' }
/**
* 키 이름 → 표기 · aria-keyshortcuts.
* - 윈도우 · 리눅스: "Ctrl+Shift+S" · "Control+Shift+S"
* - 맥: "⇧⌘S" · "Meta+Shift+S"
*/
export function formatShortcut(keys: ShortcutKeys, apple: boolean): ShortcutText {
const aria = keys.map((key) => (apple ? APPLE_ARIA_NAMES : ARIA_NAMES)[key] ?? key).join('+')
if (!apple) return { label: keys.map((key) => LABEL_NAMES[key] ?? key).join('+'), aria }
const modifiers = APPLE_MODIFIERS.filter(([key]) => keys.includes(key)).map(([, symbol]) => symbol)
const rest = keys.filter((key) => !APPLE_MODIFIERS.some(([modifier]) => modifier === key))
return { label: [...modifiers, ...rest].join(''), aria }
}
@@ -0,0 +1,60 @@
import { describe, expect, it } from 'vitest'
import { isAllowedLinkHref, normalizeLinkHref, shouldAutoLinkText } from './link-href'
describe('isAllowedLinkHref — 링크는 http · https · mailto 만 (계약 2절 "서식 본문")', () => {
it.each(['https://wiki.arasystem.it.kr/a', 'http://example.kr', 'mailto:ara.kim@arasystem.it.kr', 'HTTPS://X.KR'])(
'받는다: %s',
(href) => {
expect(isAllowedLinkHref(href)).toBe(true)
},
)
it.each([
'javascript:alert(1)',
'JaVaScRiPt:alert(1)',
'data:text/html,<script>alert(1)</script>',
'vbscript:msgbox(1)',
'file:///etc/passwd',
'ftp://example.kr',
'//example.kr',
'/mail/inbox',
'https://',
'https://exa mple.kr',
'https://example.kr\u0000',
' https://example.kr',
])('받지 않는다: %s', (href) => {
expect(isAllowedLinkHref(href)).toBe(false)
})
})
describe('normalizeLinkHref — 링크 칸에 쓴 주소', () => {
it('프로토콜이 없으면 붙인다 — 메일 주소는 mailto:, 그 밖은 https://', () => {
expect(normalizeLinkHref('wiki.arasystem.it.kr/a')).toBe('https://wiki.arasystem.it.kr/a')
expect(normalizeLinkHref(' ara.kim@arasystem.it.kr ')).toBe('mailto:ara.kim@arasystem.it.kr')
expect(normalizeLinkHref('https://example.kr')).toBe('https://example.kr')
})
it('쓸 수 없는 주소는 null — javascript: 를 https:// 로 감싸 통과시키지 않는다', () => {
expect(normalizeLinkHref('javascript:alert(1)')).toBeNull()
expect(normalizeLinkHref('')).toBeNull()
expect(normalizeLinkHref(' ')).toBeNull()
expect(normalizeLinkHref('exa mple')).toBeNull()
})
})
describe('shouldAutoLinkText — 쓰는 중에 저절로 링크로 바꿀 글', () => {
it.each(['www.naver.com', 'naver.com', 'ara.kim@arasystem.it.kr', 'https://wiki.arasystem.it.kr/a', 'mailto:ara@x.kr'])(
'바꾼다(프로토콜이 없어도): %s',
(text) => {
expect(shouldAutoLinkText(text)).toBe(true)
},
)
it.each(['1.2.3.4', '10.0.0.1/admin', 'javascript:alert(1)', 'ftp://example.kr', 'exa mple.kr', ''])(
'바꾸지 않는다 — 프로토콜 없는 IP(버전 번호 등) · 허용 밖 프로토콜: %s',
(text) => {
expect(shouldAutoLinkText(text)).toBe(false)
},
)
})
+51
View File
@@ -0,0 +1,51 @@
// 본문 링크 주소 검사 — 순수 함수 (계약 2절 "서식 본문": 링크는 http · https · mailto 만)
// 서버 정화(sanitize-outgoing-html.ts)도 같은 규칙으로 그 밖의 링크를 글자만 남기지만,
// 편집기에서부터 막아 javascript: 같은 주소가 본문에 들어가지 않게 한다.
/** 허용하는 주소의 시작 — 서버의 SAFE_LINK 와 같다 */
const SAFE_LINK = /^(?:https?:\/\/|mailto:)/i
const ALLOWED_PROTOCOLS: readonly string[] = ['http:', 'https:', 'mailto:']
/** 공백·제어 문자 — 주소 안에 있으면 받지 않는다 (숨은 문자로 검사를 비껴가지 못하게) */
const UNSAFE_CHARACTERS = /[\s\p{Cc}]/u
/** 프로토콜이 붙어 있는가 (예: https:, mailto:, javascript:) */
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i
/** 메일 주소처럼 보이는가 — 그러면 mailto: 를 붙인다 */
const EMAIL_LIKE = /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/
/** 링크로 둘 수 있는 주소인가 */
export function isAllowedLinkHref(href: string): boolean {
if (!SAFE_LINK.test(href) || UNSAFE_CHARACTERS.test(href)) return false
try {
const url = new URL(href)
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) return false
return url.protocol === 'mailto:' || url.hostname !== ''
} catch {
return false
}
}
/**
* 사용자가 링크 칸에 쓴 주소를 링크 주소로 바꾼다. 쓸 수 없으면 null.
* 프로토콜이 없으면 붙여 본다 — 메일 주소면 mailto:, 그 밖은 https://
*/
export function normalizeLinkHref(input: string): string | null {
const value = input.trim()
if (value === '') return null
let candidate = value
if (!HAS_SCHEME.test(value)) {
candidate = EMAIL_LIKE.test(value) ? `mailto:${value}` : `https://${value}`
}
return isAllowedLinkHref(candidate) ? candidate : null
}
/** 프로토콜 없이 쓴 IP 주소 — 버전 번호(1.2.3.4) 같은 글이라 저절로 링크로 바꾸지 않는다 (TipTap 기본 규칙과 같다) */
const BARE_IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}(?:[/:?#]|$)/
/**
* 쓰는 중에 저절로 링크로 바꿀 글인가 — 링크 칸과 같은 규칙으로 프로토콜을 붙여 본다.
* 그래서 www.naver.com · naver.com · 메일 주소도 링크가 된다 (실제 주소는 편집기가 https:// · mailto: 를 붙인다).
*/
export function shouldAutoLinkText(text: string): boolean {
if (!HAS_SCHEME.test(text) && BARE_IPV4.test(text)) return false
return normalizeLinkHref(text) !== null
}
+2 -41
View File
@@ -1,11 +1,7 @@
import { describe, expect, it } from 'vitest'
import {
MAIL_BODY_MAX_LENGTH,
MAIL_QUOTE_OMITTED_MARK,
MAIL_SUBJECT_MAX_LENGTH,
} from '@/shared/constants/mail'
import { fitDraftBody, fitDraftSubject, truncateText } from './mail-draft'
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import { fitDraftSubject, truncateText } from './mail-draft'
describe('truncateText', () => {
it('한도 안이면 그대로, 넘으면 앞에서부터 한도까지 남긴다', () => {
@@ -32,38 +28,3 @@ describe('fitDraftSubject — 제목 255자 (계약 2절)', () => {
expect(fitDraftSubject(over).startsWith('Re: ')).toBe(true)
})
})
describe('fitDraftBody — 본문 100,000자 (계약 2절)', () => {
const head = '\n\n──────────\n2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@arasystem.it.kr> 작성:\n'
it('100,000자면 그대로 둔다', () => {
const exact = head + 'x'.repeat(MAIL_BODY_MAX_LENGTH - head.length)
expect(fitDraftBody(exact)).toBe(exact)
})
it('100,001자면 뒤쪽 원문을 줄이고 끝에 생략 표시를 둔다 — 앞쪽(쓸 자리·구분선·원문 머리)은 그대로', () => {
const over = head + 'x'.repeat(MAIL_BODY_MAX_LENGTH - head.length + 1)
const fitted = fitDraftBody(over)
expect(fitted.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
expect(fitted.startsWith(head)).toBe(true)
expect(fitted.endsWith(`\n${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
})
it('답장은 생략 표시도 인용 줄 머리("> ")를 붙여 인용 안에 둔다', () => {
const over = head + '> 원문 줄\n'.repeat(20_000)
const fitted = fitDraftBody(over, '> ')
expect(fitted.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
expect(fitted.endsWith(`\n> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
})
it('아주 긴 원문(1MB 넘게)도 한도 안으로 줄인다 — 앞단 프록시의 413 으로 가지 않게', () => {
const huge = head + '가'.repeat(1_100_000)
expect(fitDraftBody(huge).length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
})
})
+4 -18
View File
@@ -1,13 +1,9 @@
// 답장·전달 초안을 계약 한도에 맞춘다 — 순수 함수만 둔다 (계약 2절 "답장·전달 초안의 길이")
// 서버는 제목 255자 · 본문 100,000자를 넘으면 VAL_001 로 거절한다. 본문이 1MB 를 넘으면 앞단 프록시가
// 413(FILE_002)으로 끊어 첨부 문제처럼 보이므로, 초안을 만들 때 미리 맞춘다.
// 답장·전달 초안의 제목을 계약 한도에 맞춘다 — 순수 함수만 둔다 (계약 2절 "답장·전달 초안의 길이")
// 서버는 제목 255자를 넘으면 VAL_001 로 거절한다. 본문(텍스트 100,000 · 서식 500,000)은 서식 편집기가 맞춘다
// (components/mail/editor/fit-draft.ts).
// 길이는 JS 문자열 길이(UTF-16 단위)로 센다 — 서버(글자 수)보다 같거나 길게 세므로 이 한도 안이면 서버도 통과한다.
import {
MAIL_BODY_MAX_LENGTH,
MAIL_QUOTE_OMITTED_MARK,
MAIL_SUBJECT_MAX_LENGTH,
} from '@/shared/constants/mail'
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
/** UTF-16 서로게이트 쌍의 앞쪽 범위 — 여기서 자르면 이모지 등이 깨진다 */
const HIGH_SURROGATE_START = 0xd800
@@ -26,13 +22,3 @@ export function truncateText(text: string, maxLength: number): string {
export function fitDraftSubject(subject: string): string {
return truncateText(subject, MAIL_SUBJECT_MAX_LENGTH)
}
/**
* 본문 — 100,000자를 넘으면 뒤쪽(인용한 원문)을 줄이고 끝에 생략 표시를 둔다. 앞쪽(내가 쓸 자리 · 구분선 · 원문 머리)은 남는다.
* linePrefix: 답장은 인용 줄 머리("> ")를 붙여 생략 표시도 인용 안에 둔다.
*/
export function fitDraftBody(body: string, linePrefix = ''): string {
if (body.length <= MAIL_BODY_MAX_LENGTH) return body
const mark = `\n${linePrefix}${MAIL_QUOTE_OMITTED_MARK}`
return `${truncateText(body, MAIL_BODY_MAX_LENGTH - mark.length).trimEnd()}${mark}`
}
+27 -44
View File
@@ -1,12 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
MAIL_BODY_MAX_LENGTH,
MAIL_QUOTE_OMITTED_MARK,
MAIL_SUBJECT_MAX_LENGTH,
} from '@/shared/constants/mail'
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import { buildForwardBody, buildForwardDraft, buildForwardSubject } from './mail-forward'
import { buildForwardDraft, buildForwardSubject } from './mail-forward'
const ATTACHMENT = { id: 'att1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
@@ -52,42 +48,37 @@ describe('buildForwardSubject', () => {
})
})
describe('buildForwardBody', () => {
it('구분선 + 원문 머리(보낸 사람·날짜·제목·받는 사람·참조) + 원문 그대로', () => {
expect(buildForwardBody(message(), 'Asia/Seoul')).toBe(
[
'',
'',
'──────────',
'전달된 메일',
'보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>',
'날짜: 2026. 10. 26. (월) 오전 10:41',
'제목: 메일 서버 이전 일정 공유',
'받는 사람: 김아라 <ara.kim@arasystem.it.kr>',
'참조: 인프라팀 <infra@arasystem.it.kr>',
'',
'김아라님, 안녕하세요.',
'',
'금요일 저녁에 전환하겠습니다.',
].join('\n'),
)
})
it('참조가 없으면 참조 줄을 빼고, 텍스트 본문이 없으면 HTML 을 텍스트로 바꾼다', () => {
const body = buildForwardBody(message({ cc: [], textBody: null, htmlBody: '<p>첫 줄<br>둘째 줄</p>' }), 'Asia/Seoul')
expect(body).not.toContain('참조:')
expect(body.endsWith('\n\n첫 줄\n둘째 줄')).toBe(true)
})
})
describe('buildForwardDraft', () => {
it('원본 첨부를 그대로 넘긴다 (발송 때 id 를 다시 보낸다)', () => {
const draft = buildForwardDraft(message(), 'Asia/Seoul')
expect(draft.subject).toBe('Fwd: 메일 서버 이전 일정 공유')
expect(draft.attachments).toEqual([ATTACHMENT])
expect(draft.body).toContain('전달된 메일')
})
it('본문 — 답장과 같은 "원본 메일" 머리에 받는 사람 · 참조까지 (보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목)', () => {
const { html } = buildForwardDraft(message(), 'Asia/Seoul')
expect(html).toBe(
'<p></p><blockquote data-mail-quote="">' +
'<p data-quote-header=""><strong>원본 메일</strong></p>' +
'<table data-quote-header=""><tbody>' +
'<tr><td>보낸 사람</td><td>이도윤 &lt;doyun.lee@arasystem.it.kr&gt;</td></tr>' +
'<tr><td>날짜</td><td>2026. 10. 26. (월) 오전 10:41</td></tr>' +
'<tr><td>받는 사람</td><td>김아라 &lt;ara.kim@arasystem.it.kr&gt;</td></tr>' +
'<tr><td>참조</td><td>인프라팀 &lt;infra@arasystem.it.kr&gt;</td></tr>' +
'<tr><td>제목</td><td>메일 서버 이전 일정 공유</td></tr>' +
'</tbody></table>' +
'<p>김아라님, 안녕하세요.</p><p></p><p>금요일 저녁에 전환하겠습니다.</p>' +
'</blockquote>',
)
})
it('참조가 없으면 참조 줄을 뺀다', () => {
const { html } = buildForwardDraft(message({ cc: [] }), 'Asia/Seoul')
expect(html).toContain('<td>받는 사람</td>')
expect(html).not.toContain('<td>참조</td>')
})
it('계약 한도에 맞춘다 — 원래 제목 250자면 "Fwd: " 를 붙여 255자, 251자면 255자에서 자른다', () => {
@@ -97,12 +88,4 @@ describe('buildForwardDraft', () => {
expect(fits.subject).toBe(`Fwd: ${'가'.repeat(250)}`)
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
})
it('원문이 본문 한도(100,000자)를 넘으면 원문을 줄이고 끝에 생략 표시를 둔다 — 원문 머리는 남는다', () => {
const draft = buildForwardDraft(message({ textBody: '가'.repeat(150_000) }))
expect(draft.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
expect(draft.body).toContain('보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>')
expect(draft.body.endsWith(`\n${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
})
})
+9 -41
View File
@@ -1,16 +1,16 @@
// 전달 초안 만들기 — 순수 함수만 둔다 (계약 2절 "전달")
// 제목 "Fwd:" 와 원문 본문은 클라이언트가 만들고, 원본 첨부는 id 를 그대로 다시 보낸다. inReplyTo 는 보내지 않는다.
// 제목 "Fwd:" 와 원문 인용은 클라이언트가 만들고, 원본 첨부는 id 를 그대로 다시 보낸다. inReplyTo 는 보내지 않는다.
// 본문은 서식 편집기에 넣을 HTML 이다 — "원본 메일" 머리(보낸 사람·날짜·받는 사람·참조·제목)와 회색 인용 막대 (mail-quote.ts).
import { MAIL_FORWARD_HEADING, MAIL_FORWARD_PREFIX, MAIL_QUOTE_SEPARATOR } from '@/shared/constants/mail'
import { MAIL_FORWARD_PREFIX } from '@/shared/constants/mail'
import type { Attachment, MessageDetail } from '@/shared/types/mail'
import { formatMailDateTime } from '@/shared/utils/format'
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
import { fitDraftBody, fitDraftSubject } from '@/shared/utils/mail-draft'
import { messageBodyText } from '@/shared/utils/mail-html'
import { fitDraftSubject } from '@/shared/utils/mail-draft'
import { buildQuoteHtml, forwardQuoteRows, originalBodyHtml } from '@/shared/utils/mail-quote'
export interface ForwardDraft {
subject: string
body: string
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 전달하는 말을 쓴다) + 원문 인용 */
html: string
/** 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다 */
attachments: Attachment[]
}
@@ -24,43 +24,11 @@ export function buildForwardSubject(subject: string): string {
return `${MAIL_FORWARD_PREFIX}${trimmed}`.trimEnd()
}
type ForwardSource = Pick<
MessageDetail,
'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt' | 'textBody' | 'htmlBody'
>
/**
* 전달 본문 — 원문 머리(보낸 사람·날짜·제목·받는 사람·참조) 뒤에 원문을 그대로 붙인다.
*
* (빈 줄 — 여기에 전달하는 말을 쓴다)
*
* ──────────
* 전달된 메일
* 보낸 사람: 이도윤 <doyun.lee@…>
* 날짜: 2026. 10. 26. (월) 오전 10:41
* 제목: 메일 서버 이전 일정 공유
* 받는 사람: 김아라 <ara.kim@…>
*
* 원문 …
*/
export function buildForwardBody(message: ForwardSource, timeZone?: string): string {
const header = [
MAIL_FORWARD_HEADING,
message.from ? `보낸 사람: ${formatAddress(message.from)}` : null,
`날짜: ${formatMailDateTime(message.sentAt ?? message.receivedAt, timeZone)}`,
`제목: ${message.subject || '(제목 없음)'}`,
message.to.length > 0 ? `받는 사람: ${formatAddressList(message.to)}` : null,
message.cc.length > 0 ? `참조: ${formatAddressList(message.cc)}` : null,
].filter((line): line is string => line !== null)
return `\n\n${MAIL_QUOTE_SEPARATOR}\n${header.join('\n')}\n\n${messageBodyText(message)}`
}
/** 전달 초안 — 제목·본문은 계약 한도(255자·100,000자)에 맞춘다. 넘치면 원문을 줄이고 끝에 생략 표시를 둔다 */
/** 전달 초안 — 제목은 255자에 맞춘다 */
export function buildForwardDraft(message: MessageDetail, timeZone?: string): ForwardDraft {
return {
subject: fitDraftSubject(buildForwardSubject(message.subject)),
body: fitDraftBody(buildForwardBody(message, timeZone)),
html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message)),
attachments: [...message.attachments],
}
}
+1 -13
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, htmlToText, sanitizeMailHtml } from './mail-html'
import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, sanitizeMailHtml } from './mail-html'
// 보안 핵심 — 서버 정화에 빈틈이 있어도 클라이언트가 한 번 더 걸러 내는지 확인한다.
describe('sanitizeMailHtml', () => {
@@ -131,15 +131,3 @@ describe('escapeHtml', () => {
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe('&lt;a href=&quot;x&quot;&gt;&#39;&amp;&#39;&lt;/a&gt;')
})
})
describe('htmlToText (답장 인용용)', () => {
it('문단·줄바꿈을 살려 텍스트로 바꾼다', () => {
expect(htmlToText('<p>김아라님,</p>\n <p>안녕하세요.<br>확인 부탁드립니다.</p>')).toBe(
'김아라님,\n\n안녕하세요.\n확인 부탁드립니다.',
)
})
it('스크립트 내용은 텍스트로 새지 않는다', () => {
expect(htmlToText('<p>본문</p><script>alert(1)</script>')).toBe('본문')
})
})
-65
View File
@@ -154,68 +154,3 @@ export function buildMailDocument(source: MailBodySource): string {
'</html>',
].join('')
}
const BLOCK_TAGS = new Set([
'P',
'DIV',
'LI',
'TR',
'H1',
'H2',
'H3',
'H4',
'H5',
'H6',
'BLOCKQUOTE',
'PRE',
'TABLE',
'UL',
'OL',
'SECTION',
'ARTICLE',
'HEADER',
'FOOTER',
])
/**
* 답장 인용용 — HTML 본문을 일반 텍스트로 바꾼다.
* 정화한 뒤 DOMParser 로 읽는다 (DOMParser 문서는 스크립트를 실행하지 않고 이미지도 내려받지 않는다).
*/
export function htmlToText(html: string): string {
const doc = new DOMParser().parseFromString(sanitizeMailHtml(html), 'text/html')
const chunks: string[] = []
const walk = (node: Node, preformatted: boolean) => {
if (node.nodeType === Node.TEXT_NODE) {
const text = node.nodeValue ?? ''
chunks.push(preformatted ? text : text.replace(/\s+/g, ' '))
return
}
if (node.nodeType !== Node.ELEMENT_NODE) return
const element = node as Element
if (element.tagName === 'BR') {
chunks.push('\n')
return
}
const isBlock = BLOCK_TAGS.has(element.tagName)
if (isBlock) chunks.push('\n')
const childPreformatted = preformatted || element.tagName === 'PRE'
element.childNodes.forEach((child) => walk(child, childPreformatted))
if (isBlock) chunks.push('\n')
}
walk(doc.body, false)
return chunks
.join('')
.replace(/[ \t]+\n/g, '\n')
.replace(/\n[ \t]+/g, '\n')
.replace(/\n{3,}/g, '\n\n')
.trim()
}
/** 답장·전달에 넣을 원문 텍스트 — 텍스트 본문이 없으면 HTML 을 텍스트로 바꾼다 */
export function messageBodyText(source: MailBodySource): string {
if (source.textBody !== null) return source.textBody.replace(/\r\n?/g, '\n')
return source.htmlBody ? htmlToText(source.htmlBody) : ''
}
@@ -0,0 +1,94 @@
import { describe, expect, it } from 'vitest'
import { MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import {
QUOTE_SOURCE_MAX_LENGTH,
buildQuoteHtml,
forwardQuoteRows,
originalBodyHtml,
replyQuoteRows,
textToParagraphHtml,
} from './mail-quote'
/** 잘랐을 때 끝에 두는 생략 표시 문단 */
const OMITTED = `<p>${MAIL_QUOTE_OMITTED_MARK}</p>`
const HEADER: Pick<MessageDetail, 'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt'> = {
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
cc: [],
subject: '',
receivedAt: '2026-10-26T01:42:00Z',
sentAt: null,
}
describe('textToParagraphHtml', () => {
it('한 줄이 한 문단 — 빈 줄은 빈 문단, 특수 문자는 바꾼다', () => {
expect(textToParagraphHtml('a <b>\r\n\r\n"c" & d')).toBe('<p>a &lt;b&gt;</p><p></p><p>&quot;c&quot; &amp; d</p>')
})
})
describe('originalBodyHtml', () => {
it('서식 본문이 있으면 그것, 없으면 텍스트를 문단으로', () => {
expect(originalBodyHtml({ htmlBody: '<p>서식</p>', textBody: '텍스트' })).toBe('<p>서식</p>')
expect(originalBodyHtml({ htmlBody: null, textBody: '한 줄\n두 줄' })).toBe('<p>한 줄</p><p>두 줄</p>')
expect(originalBodyHtml({ htmlBody: null, textBody: null })).toBe('<p></p>')
})
it('아주 긴 원문은 편집기로 옮기기 전에 자르고 끝에 생략 표시를 둔다 (정확한 맞춤은 편집기가 한다)', () => {
const html = originalBodyHtml({ htmlBody: 'x'.repeat(QUOTE_SOURCE_MAX_LENGTH + 10), textBody: null })
expect(html).toBe(`${'x'.repeat(QUOTE_SOURCE_MAX_LENGTH)}${OMITTED}`)
expect(originalBodyHtml({ htmlBody: null, textBody: 'y'.repeat(QUOTE_SOURCE_MAX_LENGTH + 1) }).endsWith(OMITTED)).toBe(true)
// 한도 안이면 표시를 두지 않는다
expect(originalBodyHtml({ htmlBody: '<p>짧은 원문</p>', textBody: null })).not.toContain(MAIL_QUOTE_OMITTED_MARK)
})
it('태그 한가운데서 자르지 않는다 — 닫히지 않은 태그는 그 앞에서 자른다', () => {
const head = 'x'.repeat(QUOTE_SOURCE_MAX_LENGTH - 5)
const html = originalBodyHtml({ htmlBody: `${head}<span style="color: #B42318">뒤</span>`, textBody: null })
expect(html).toBe(`${head}${OMITTED}`)
})
it('그림(서버가 넣은 data: 그림 포함)은 자르기 전에 걷어 낸다 — 큰 그림 뒤의 글이 생략 표시 없이 사라지지 않게', () => {
const picture = `<img src="data:image/png;base64,${'A'.repeat(QUOTE_SOURCE_MAX_LENGTH + 100_000)}" alt="서명">`
const html = originalBodyHtml({ htmlBody: `<p>앞 글</p><p>${picture}</p><p>뒤쪽 본문 내용</p>`, textBody: null })
expect(html).toBe('<p>앞 글</p><p></p><p>뒤쪽 본문 내용</p>')
expect(html).not.toContain('data:')
})
})
describe('인용 머리', () => {
it('답장 — 보낸 사람 · 날짜 · 제목 (제목이 없으면 "(제목 없음)")', () => {
expect(replyQuoteRows(HEADER, 'Asia/Seoul')).toEqual([
{ label: '보낸 사람', value: '이도윤 <doyun.lee@arasystem.it.kr>' },
{ label: '날짜', value: '2026. 10. 26. (월) 오전 10:42' },
{ label: '제목', value: '(제목 없음)' },
])
})
it('전달 — 받는 사람 · 참조까지 (비었으면 뺀다), 보낸 날짜가 있으면 그것', () => {
const rows = forwardQuoteRows(
{ ...HEADER, subject: '일정', sentAt: '2026-10-26T01:41:00Z', cc: [{ name: '', email: 'infra@arasystem.it.kr' }] },
'Asia/Seoul',
)
expect(rows.map((row) => row.label)).toEqual(['보낸 사람', '날짜', '받는 사람', '참조', '제목'])
expect(rows[1]?.value).toBe('2026. 10. 26. (월) 오전 10:41')
expect(forwardQuoteRows({ ...HEADER, to: [] }).map((row) => row.label)).toEqual(['보낸 사람', '날짜', '제목'])
})
})
describe('buildQuoteHtml', () => {
it('내가 쓸 빈 문단 + "원본 메일" + 항목 표 + 원문 — 머리 값은 이스케이프한다', () => {
expect(buildQuoteHtml([{ label: '제목', value: '<script>' }], '<p>원문</p>')).toBe(
'<p></p><blockquote data-mail-quote="">' +
'<p data-quote-header=""><strong>원본 메일</strong></p>' +
'<table data-quote-header=""><tbody><tr><td>제목</td><td>&lt;script&gt;</td></tr></tbody></table>' +
'<p>원문</p></blockquote>',
)
})
})
+115
View File
@@ -0,0 +1,115 @@
// 답장·전달 초안의 원문 인용 HTML — 순수 함수 (계약 2절 "서식 본문", 디자인 README "답장 · 전달의 원문 인용")
// 서식 편집기가 이 HTML 을 자기 스키마로 옮긴다 — 편집기가 모르는 요소(이미지 등)는 빠진다.
// 한도(텍스트 100,000자 · 서식 500,000자)에 맞추는 일도 편집기가 한다 (components/mail/editor/fit-draft.ts).
//
// (빈 문단 — 여기에 쓴다)
// ┃ 원본 메일 ← data-quote-header 문단 (굵게)
// ┃ 보낸 사람 이도윤 <doyun.lee@…> ← data-quote-header 표 (항목 이름 · 값)
// ┃ 날짜 2026. 10. 26. (월) 오전 10:42
// ┃ 제목 메일 서버 이전 일정 공유
// ┃ 원문 …
//
// data-* 표시는 편집기가 인용 머리를 알아보고 모양을 입히는 데만 쓴다 (서버 정화가 지운다).
import { MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
import { MAIL_QUOTE_HEADING } from '@/shared/constants/mail-editor'
import type { MessageDetail } from '@/shared/types/mail'
import { formatMailDateTime } from '@/shared/utils/format'
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
import { truncateText } from '@/shared/utils/mail-draft'
import { escapeHtml } from '@/shared/utils/mail-html'
export interface QuoteRow {
label: string
value: string
}
/**
* 원문을 편집기로 옮기기 전에 자르는 길이 — 정확한 맞춤은 편집기가 하고, 이 값은 아주 긴 원문의 파싱 시간만 줄인다.
* 서식 본문 한도(500,000자)의 네 배라 맞춤에 쓸 내용이 모자라지 않다. 잘랐으면 끝에 생략 표시를 둔다.
*/
export const QUOTE_SOURCE_MAX_LENGTH = 2_000_000
/**
* 원문의 그림 태그 — 편집기는 그림을 받지 않는다(본문 이미지는 첨부로). 서버는 본문 안 그림(cid:)을 data: URI 로 넣어 주므로
* 그대로 두면 큰 그림 몇 장이 자르는 길이를 다 써서 뒤쪽 글이 생략 표시도 없이 사라진다 — 자르기 전에 걷어 낸다.
* htmlBody 는 서버가 정화한 HTML 이라(속성 값의 `>` 는 &gt; 로 바뀌어 있다) 태그 단위로 걷어 낼 수 있다.
*/
const IMAGE_TAG = /<img\b[^>]*>/gi
/** 생략 표시 문단 — 편집기의 맞춤(fit-draft)과 같은 글 */
const OMITTED_PARAGRAPH = `<p>${escapeHtml(MAIL_QUOTE_OMITTED_MARK)}</p>`
/** HTML 을 길이에 맞춰 자른다 — 태그 한가운데서 자르지 않고, 잘랐으면 끝에 생략 표시를 둔다 */
function truncateHtml(html: string, maxLength: number): string {
if (html.length <= maxLength) return html
let cut = truncateText(html, maxLength)
// 마지막 태그가 닫히지 않았으면 그 태그 앞에서 자른다
const lastOpen = cut.lastIndexOf('<')
if (lastOpen > cut.lastIndexOf('>')) cut = cut.slice(0, lastOpen)
return `${cut}${OMITTED_PARAGRAPH}`
}
const NO_SUBJECT = '(제목 없음)'
/** 일반 텍스트 → 문단 HTML — 한 줄이 한 문단, 빈 줄은 빈 문단 (Enter 한 번이 한 줄인 편집기와 같다) */
export function textToParagraphHtml(text: string): string {
return text
.replace(/\r\n?/g, '\n')
.split('\n')
.map((line) => `<p>${escapeHtml(line)}</p>`)
.join('')
}
type OriginalSource = Pick<MessageDetail, 'htmlBody' | 'textBody'>
/** 원문 본문 — 서버가 정화한 htmlBody(그림을 걷어 낸 것), 없으면 textBody 를 문단으로. 아주 길면 자르고 생략 표시 */
export function originalBodyHtml(message: OriginalSource): string {
if (message.htmlBody) return truncateHtml(message.htmlBody.replace(IMAGE_TAG, ''), QUOTE_SOURCE_MAX_LENGTH)
const text = message.textBody ?? ''
if (text.length <= QUOTE_SOURCE_MAX_LENGTH) return textToParagraphHtml(text)
return `${textToParagraphHtml(truncateText(text, QUOTE_SOURCE_MAX_LENGTH))}${OMITTED_PARAGRAPH}`
}
type HeaderSource = Pick<MessageDetail, 'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt'>
function dateRow(message: HeaderSource, timeZone?: string): QuoteRow {
return { label: '날짜', value: formatMailDateTime(message.sentAt ?? message.receivedAt, timeZone) }
}
function fromRows(message: HeaderSource): QuoteRow[] {
return message.from ? [{ label: '보낸 사람', value: formatAddress(message.from) }] : []
}
function subjectRow(message: HeaderSource): QuoteRow {
return { label: '제목', value: message.subject || NO_SUBJECT }
}
/** 답장 머리 — 보낸 사람 · 날짜 · 제목 */
export function replyQuoteRows(message: HeaderSource, timeZone?: string): QuoteRow[] {
return [...fromRows(message), dateRow(message, timeZone), subjectRow(message)]
}
/** 전달 머리 — 보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목 */
export function forwardQuoteRows(message: HeaderSource, timeZone?: string): QuoteRow[] {
const rows: QuoteRow[] = [...fromRows(message), dateRow(message, timeZone)]
if (message.to.length > 0) rows.push({ label: '받는 사람', value: formatAddressList(message.to) })
if (message.cc.length > 0) rows.push({ label: '참조', value: formatAddressList(message.cc) })
rows.push(subjectRow(message))
return rows
}
/** 내가 쓸 빈 문단 + "원본 메일" 머리 + 원문을 담은 인용 */
export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string): string {
const header = rows
.map((row) => `<tr><td>${escapeHtml(row.label)}</td><td>${escapeHtml(row.value)}</td></tr>`)
.join('')
return (
'<p></p>' +
'<blockquote data-mail-quote="">' +
`<p data-quote-header=""><strong>${escapeHtml(MAIL_QUOTE_HEADING)}</strong></p>` +
`<table data-quote-header=""><tbody>${header}</tbody></table>` +
bodyHtml +
'</blockquote>'
)
}
+37 -47
View File
@@ -1,17 +1,8 @@
import { describe, expect, it } from 'vitest'
import {
MAIL_BODY_MAX_LENGTH,
MAIL_QUOTE_OMITTED_MARK,
MAIL_SUBJECT_MAX_LENGTH,
} from '@/shared/constants/mail'
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
import type { MessageDetail } from '@/shared/types/mail'
import {
buildQuotedBody,
buildReplyDraft,
buildReplyRecipients,
buildReplySubject,
} from './mail-reply'
import { buildReplyDraft, buildReplyRecipients, buildReplySubject } from './mail-reply'
const ME = 'ara.kim@arasystem.it.kr'
const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }
@@ -84,41 +75,49 @@ describe('buildReplyRecipients', () => {
})
})
describe('buildQuotedBody', () => {
it('구분선 + "날짜, 보낸 사람 작성:" + 인용 (디자인 원본 Compose)', () => {
expect(buildQuotedBody(message(), 'Asia/Seoul')).toBe(
[
'',
'',
'──────────',
'2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@arasystem.it.kr> 작성:',
'> 김아라님, 안녕하세요.',
'>',
'> 금요일 저녁에 전환하겠습니다.',
].join('\n'),
)
})
it('텍스트 본문이 없으면 HTML 을 텍스트로 바꿔 인용한다', () => {
const body = buildQuotedBody(
message({ textBody: null, htmlBody: '<p>첫 줄<br>둘째 줄</p>' }),
'Asia/Seoul',
)
expect(body.endsWith('> 첫 줄\n> 둘째 줄')).toBe(true)
})
})
describe('buildReplyDraft', () => {
it('받는 사람 · Re: 제목 · 인용 본문 · inReplyTo(메일 id) 를 채운다', () => {
it('받는 사람 · Re: 제목 · inReplyTo(메일 id) 를 채운다', () => {
const draft = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul')
expect(draft.to).toEqual([DOYUN])
expect(draft.subject).toBe('Re: 메일 서버 이전 일정 공유')
expect(draft.body).toContain('> 금요일 저녁에 전환하겠습니다.')
expect(draft.inReplyTo).toBe('m1')
})
it('본문 — 맨 위 빈 줄 + "원본 메일" 머리(보낸 사람 · 날짜 · 제목) + 원문을 인용 막대 안에 (디자인 README "원문 인용")', () => {
const { html } = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul')
expect(html).toBe(
'<p></p><blockquote data-mail-quote="">' +
'<p data-quote-header=""><strong>원본 메일</strong></p>' +
'<table data-quote-header=""><tbody>' +
'<tr><td>보낸 사람</td><td>이도윤 &lt;doyun.lee@arasystem.it.kr&gt;</td></tr>' +
'<tr><td>날짜</td><td>2026. 10. 26. (월) 오전 10:42</td></tr>' +
'<tr><td>제목</td><td>메일 서버 이전 일정 공유</td></tr>' +
'</tbody></table>' +
'<p>김아라님, 안녕하세요.</p><p></p><p>금요일 저녁에 전환하겠습니다.</p>' +
'</blockquote>',
)
})
it('답장 머리에는 받는 사람 · 참조가 없다 (전달에만)', () => {
const { html } = buildReplyDraft(message({ cc: [INFRA] }), ME, 'reply', 'Asia/Seoul')
expect(html).not.toContain('<td>받는 사람</td>')
expect(html).not.toContain('<td>참조</td>')
})
it('서식 본문(htmlBody)이 있으면 그것을 인용한다 — 편집기가 모르는 요소는 편집기가 뺀다', () => {
const { html } = buildReplyDraft(
message({ htmlBody: '<p><strong>금요일</strong> 저녁</p>', textBody: '텍스트' }),
ME,
'reply',
)
expect(html).toContain('<p><strong>금요일</strong> 저녁</p></blockquote>')
expect(html).not.toContain('텍스트')
})
it('계약 한도에 맞춘다 — 원래 제목 251자면 "Re: " 를 붙여 255자, 252자면 255자에서 자른다', () => {
const fits = buildReplyDraft(message({ subject: '가'.repeat(251) }), ME, 'reply')
const over = buildReplyDraft(message({ subject: '가'.repeat(252) }), ME, 'reply')
@@ -126,13 +125,4 @@ describe('buildReplyDraft', () => {
expect(fits.subject).toBe(`Re: ${'가'.repeat(251)}`)
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
})
it('인용이 본문 한도(100,000자)를 넘으면 인용을 줄이고 인용 안에 생략 표시를 둔다', () => {
const longText = '긴 원문 줄입니다.\n'.repeat(20_000)
const draft = buildReplyDraft(message({ textBody: longText }), ME, 'reply')
expect(draft.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
expect(draft.body.startsWith('\n\n──────────\n')).toBe(true)
expect(draft.body.endsWith(`\n> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
})
})
+11 -37
View File
@@ -1,15 +1,12 @@
// 답장 초안 만들기 — 순수 함수만 둔다 (htmlToText 는 DOMParser 를 쓰지만 결과는 입력에만 의존한다)
// 답장 초안 만들기 — 순수 함수만 둔다
// 본문은 서식 편집기에 넣을 HTML 이다 — "원본 메일" 머리(보낸 사람·날짜·제목)와 회색 인용 막대 (mail-quote.ts).
// 본문 한도(텍스트 100,000 · 서식 500,000)에 맞추는 일은 편집기가 한다 (components/mail/editor/fit-draft.ts).
import {
MAIL_QUOTE_LINE_PREFIX,
MAIL_QUOTE_SEPARATOR,
MAIL_REPLY_PREFIX,
} from '@/shared/constants/mail'
import { MAIL_REPLY_PREFIX } from '@/shared/constants/mail'
import type { Address, MessageDetail } from '@/shared/types/mail'
import { formatMailDateTime } from '@/shared/utils/format'
import { formatAddress, normalizeEmail, sameAddress, uniqueAddresses } from '@/shared/utils/mail-address'
import { fitDraftBody, fitDraftSubject } from '@/shared/utils/mail-draft'
import { messageBodyText } from '@/shared/utils/mail-html'
import { normalizeEmail, sameAddress, uniqueAddresses } from '@/shared/utils/mail-address'
import { fitDraftSubject } from '@/shared/utils/mail-draft'
import { buildQuoteHtml, originalBodyHtml, replyQuoteRows } from '@/shared/utils/mail-quote'
export type ReplyMode = 'reply' | 'replyAll'
@@ -17,7 +14,8 @@ export interface ReplyDraft {
to: Address[]
cc: Address[]
subject: string
body: string
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 쓴다) + 원문 인용 */
html: string
/** 발송 본문의 inReplyTo — 답장할 메일의 id */
inReplyTo: string
}
@@ -61,31 +59,7 @@ export function buildReplyRecipients(
return { to, cc }
}
type QuoteSource = Pick<MessageDetail, 'from' | 'receivedAt' | 'sentAt' | 'textBody' | 'htmlBody'>
/**
* 인용 본문 (디자인 원본 Compose):
*
* (빈 줄 — 여기에 답장을 쓴다)
*
* ──────────
* 2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@…> 작성:
* > 원문 첫 줄
*/
export function buildQuotedBody(message: QuoteSource, timeZone?: string): string {
const author = message.from ? formatAddress(message.from) : ''
const date = formatMailDateTime(message.sentAt ?? message.receivedAt, timeZone)
const header = [date, author].filter(Boolean).join(', ')
const quoted = messageBodyText(message)
.split('\n')
.map((line) => (line ? `${MAIL_QUOTE_LINE_PREFIX}${line}` : MAIL_QUOTE_LINE_PREFIX.trimEnd()))
.join('\n')
return `\n\n${MAIL_QUOTE_SEPARATOR}\n${header} 작성:\n${quoted}`
}
/** 답장 초안 — 제목·본문은 계약 한도(255자·100,000자)에 맞춘다. 넘치면 인용을 줄이고 인용 안에 생략 표시를 둔다 */
/** 답장 초안 — 제목은 255자에 맞춘다 */
export function buildReplyDraft(
message: MessageDetail,
myEmail: string,
@@ -97,7 +71,7 @@ export function buildReplyDraft(
to,
cc,
subject: fitDraftSubject(buildReplySubject(message.subject)),
body: fitDraftBody(buildQuotedBody(message, timeZone), MAIL_QUOTE_LINE_PREFIX),
html: buildQuoteHtml(replyQuoteRows(message, timeZone), originalBodyHtml(message)),
inReplyTo: message.id,
}
}
@@ -0,0 +1,101 @@
import { describe, expect, it } from 'vitest'
import { MAIL_HTML_STYLES } from '@/components/mail/editor/mail-serialize'
import {
EDITOR_BACKGROUND_COLORS,
EDITOR_FONTS,
EDITOR_FONT_SIZES,
EDITOR_INDENT_STEP_PX,
EDITOR_MAX_INDENT,
EDITOR_TEXT_COLORS,
MAIL_BODY_FONT_FAMILY,
MAIL_BODY_FONT_SIZE_PX,
MAIL_BODY_LINE_HEIGHT,
} from '@/shared/constants/mail-editor'
import { STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
import {
acceptStyleValue,
cellSpanOf,
isOutgoingStyleValue,
lengthToPx,
listStartOf,
} from './outgoing-html-rules'
describe('isOutgoingStyleValue — 서버 정화 허용 목록과 같은 값 형식 (계약 2절 "서식 본문")', () => {
it.each(STYLE_SAMPLES)('%s: %s → 남는다 %s', (property, value, kept) => {
expect(isOutgoingStyleValue(property, value)).toBe(kept)
})
it('속성 이름은 작은 글자만 맞는다 — 객체의 기본 속성 이름도 규칙이 아니다', () => {
expect(isOutgoingStyleValue('COLOR', '#fff')).toBe(false)
expect(isOutgoingStyleValue('constructor', 'x')).toBe(false)
expect(isOutgoingStyleValue('__proto__', 'x')).toBe(false)
})
})
describe('편집기가 만드는 서식은 모두 서버 형식 안이다 — 보내면 그대로 남는다', () => {
it('글꼴 목록 · 기본 글꼴 · 글자 크기 · 팔레트 색 · 들여쓰기 · 줄 간격', () => {
for (const font of EDITOR_FONTS) expect(isOutgoingStyleValue('font-family', font.family)).toBe(true)
expect(isOutgoingStyleValue('font-family', MAIL_BODY_FONT_FAMILY)).toBe(true)
for (const size of [...EDITOR_FONT_SIZES, MAIL_BODY_FONT_SIZE_PX]) {
expect(isOutgoingStyleValue('font-size', `${size}px`)).toBe(true)
}
for (const color of [...EDITOR_TEXT_COLORS, ...EDITOR_BACKGROUND_COLORS]) {
if (color.value !== null) expect(isOutgoingStyleValue('color', color.value)).toBe(true)
}
expect(isOutgoingStyleValue('margin-left', `${EDITOR_INDENT_STEP_PX * EDITOR_MAX_INDENT}px`)).toBe(true)
expect(isOutgoingStyleValue('line-height', String(MAIL_BODY_LINE_HEIGHT))).toBe(true)
})
it('받는 쪽 모양(MAIL_HTML_STYLES)의 선언 하나하나', () => {
for (const style of Object.values(MAIL_HTML_STYLES)) {
for (const declaration of style.split(';')) {
const [property = '', ...value] = declaration.split(':')
expect(isOutgoingStyleValue(property.trim(), value.join(':')), declaration).toBe(true)
}
}
})
})
describe('acceptStyleValue — 들어온 값(붙여 넣기 · 원문 인용)을 보낼 수 있는 값으로', () => {
it('형식에 맞으면 그대로, 틀리면 null — !important 는 떼고 본다', () => {
expect(acceptStyleValue('color', ' #B42318 ')).toBe('#B42318')
expect(acceptStyleValue('color', '#B42318 !important')).toBe('#B42318')
expect(acceptStyleValue('color', 'transparent')).toBeNull()
expect(acceptStyleValue('font-family', 'Calibri, sans-serif')).toBeNull()
expect(acceptStyleValue('color', null)).toBeNull()
})
it('글자 크기 — pt 는 px 로, 소수 셋째 자리부터는 반올림 (워드 · 아웃룩)', () => {
expect(acceptStyleValue('font-size', '10pt')).toBe('13.33px')
expect(acceptStyleValue('font-size', '12.0pt')).toBe('16px')
expect(acceptStyleValue('font-size', '13.3333px')).toBe('13.33px')
expect(acceptStyleValue('font-size', '18PX')).toBe('18px')
expect(acceptStyleValue('font-size', '0')).toBeNull()
expect(acceptStyleValue('font-size', '0pt')).toBeNull()
expect(acceptStyleValue('font-size', '1.2em')).toBeNull()
expect(acceptStyleValue('font-size', '80pt')).toBeNull()
})
it('lengthToPx — px · pt 만 (기준 크기를 모르는 em · % 는 null)', () => {
expect(lengthToPx('40px')).toBe(40)
expect(lengthToPx('36pt')).toBe(48)
expect(lengthToPx('2em')).toBeNull()
expect(lengthToPx('-10px')).toBeNull()
})
})
describe('숫자 속성 — 서버가 남기는 1~4자리 숫자', () => {
it('목록 시작 번호 — 0~9999, 그 밖은 1', () => {
expect(listStartOf('3')).toBe(3)
expect(listStartOf(0)).toBe(0)
expect(listStartOf(9999)).toBe(9999)
for (const value of [10_000, -2, '3x', '2;', Number.NaN, 2.5, null, undefined]) expect(listStartOf(value)).toBe(1)
})
it('칸 합치기 — 1~1000, 형식이 틀리면 1', () => {
expect(cellSpanOf('2')).toBe(2)
expect(cellSpanOf('5000')).toBe(1000)
for (const value of ['0', '2;', 'abc', '', null, -1, 10_000]) expect(cellSpanOf(value)).toBe(1)
})
})
@@ -0,0 +1,185 @@
// 보내는 서식 본문(bodyHtml)에 남는 값의 형식 — 순수 함수 (계약 2절 "서식 본문")
//
// ⚠️ 서버 정화 허용 목록과 **같은 형식**이다. 짝: backend/src/modules/mail/html/sanitize-outgoing-html.ts 의
// STYLE_RULES · OUTGOING_FONT_NAMES · SMALL_NUMBER. 한쪽을 고치면 다른 쪽도 같이 고친다 —
// 같은 표본 표를 양쪽 테스트에 둔다 (test-utils/outgoing-style-samples.ts 의 STYLE_SAMPLES ↔ sanitize-outgoing-html.spec.ts 의 "값 형식 표본").
//
// 쓰는 곳: 서식 편집기가 붙여 넣은 글 · 원문 인용을 읽어 들일 때 이 형식에 맞는 값만 받는다 (editor/mail-format-extensions.ts).
// 서버만 지우면 편집기에서만 보이는(또는 숨는) 모양이 생긴다 — 예: font-size:0 으로 숨긴 글이 받는 사람에게는 보인다.
// 그래서 편집기에서도 처음부터 버린다 — 편집기에 보이는 모양 = 받는 사람이 보는 모양.
// ── style 값 형식 (서버 STYLE_RULES 와 같다 — 모두 처음부터 끝까지, 대소문자 무시) ──────────────
/** px 길이 — 0 또는 0~999.99px. 음수는 받지 않는다 */
const PX = String.raw`(?:0|\d{1,3}(?:\.\d{1,2})?px)`
/** 칸 폭 — px · % · auto */
const WIDTH = String.raw`(?:${PX}|\d{1,3}(?:\.\d{1,2})?%|auto)`
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만) */
const COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d{1,3})\s*)?\))`
const BORDER_STYLE = String.raw`(?:none|hidden|dotted|dashed|solid|double)`
const BORDER_WIDTH = String.raw`(?:${PX}|thin|medium|thick)`
const BORDER_PART = String.raw`(?:${BORDER_WIDTH}|${BORDER_STYLE}|${COLOR})`
/** 글자 크기 — px 만, 1~99.99px */
const FONT_SIZE = String.raw`(?:[1-9]\d?(?:\.\d{1,2})?px)`
/** 줄 간격 — 단위 없는 숫자 1~3, 소수 둘째 자리까지 */
const LINE_HEIGHT = String.raw`(?:[12](?:\.\d{1,2})?|3(?:\.0{1,2})?)`
/**
* 받는 글꼴 이름 — 쓰기 화면의 글꼴 목록 · 기본 스택과 흔히 함께 적는 대체 글꼴 · 일반 글꼴 이름.
* 목록 밖 이름(Calibri 등)이 하나라도 섞인 font-family 는 통째로 받지 않는다 (서버와 같다).
*/
export const OUTGOING_FONT_NAMES = [
'Malgun Gothic',
'맑은 고딕',
'Apple SD Gothic Neo',
'Gulim',
'굴림',
'Dotum',
'돋움',
'Batang',
'바탕',
'AppleGothic',
'AppleMyungjo',
'Arial',
'Helvetica',
'Verdana',
'Georgia',
'Times New Roman',
'Times',
'Courier New',
'Courier',
'sans-serif',
'serif',
'monospace',
] as const
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
/** 글꼴 이름 하나 — 그대로 · 작은따옴표 · 큰따옴표 (긴 이름부터 맞춰 본다) */
const FONT_NAME = (() => {
const names = [...OUTGOING_FONT_NAMES]
.sort((a, b) => b.length - a.length)
.map(escapeRegExp)
.join('|')
return `(?:(?:${names})|'(?:${names})'|"(?:${names})")`
})()
const exactly = (pattern: string) => new RegExp(`^${pattern}$`, 'i')
const COLOR_VALUE = exactly(COLOR)
const PX_VALUE = exactly(PX)
const WIDTH_VALUE = exactly(WIDTH)
/** margin · padding 줄임형 — px 길이 1~4개 */
const BOX_VALUE = exactly(String.raw`${PX}(?:\s+${PX}){0,3}`)
const BORDER_VALUE = exactly(String.raw`${BORDER_PART}(?:\s+${BORDER_PART}){0,2}`)
/**
* 서버가 남기는 style 속성과 값 형식 — 어느 태그에 오든 같은 규칙이다.
* 여기 없는 속성(display · position · visibility · opacity · text-decoration 등)은 서버가 지운다.
*/
export const OUTGOING_STYLE_RULES = {
color: COLOR_VALUE,
'background-color': COLOR_VALUE,
'font-family': exactly(String.raw`${FONT_NAME}(?:\s*,\s*${FONT_NAME}){0,9}`),
'font-size': exactly(FONT_SIZE),
'font-weight': exactly('(?:normal|bold|[1-9]00)'),
'text-align': exactly('(?:left|right|center|justify)'),
margin: BOX_VALUE,
'margin-top': PX_VALUE,
'margin-right': PX_VALUE,
'margin-bottom': PX_VALUE,
'margin-left': PX_VALUE,
padding: BOX_VALUE,
'padding-top': PX_VALUE,
'padding-right': PX_VALUE,
'padding-bottom': PX_VALUE,
'padding-left': PX_VALUE,
border: BORDER_VALUE,
'border-top': BORDER_VALUE,
'border-right': BORDER_VALUE,
'border-bottom': BORDER_VALUE,
'border-left': BORDER_VALUE,
'border-color': COLOR_VALUE,
'border-style': exactly(BORDER_STYLE),
'border-width': exactly(BORDER_WIDTH),
'border-collapse': exactly('(?:collapse|separate)'),
width: WIDTH_VALUE,
'min-width': WIDTH_VALUE,
'vertical-align': exactly('(?:top|middle|bottom|baseline)'),
'white-space': exactly('(?:normal|nowrap)'),
'line-height': exactly(LINE_HEIGHT),
} as const satisfies Record<string, RegExp>
export type OutgoingStyleProperty = keyof typeof OUTGOING_STYLE_RULES
/**
* 서버가 남기는 style 선언인가.
* value 는 서버가 보는 값과 같은 모양으로 넘긴다 — 앞뒤 공백은 괜찮고 `!important` 는 뗀 값 (CSS 파서가 값과 나눠 읽는다).
* 속성 이름은 작은 글자만 맞는다 (서버도 이름을 대소문자 가려 비교한다).
*/
export function isOutgoingStyleValue(property: string, value: string): boolean {
if (!Object.hasOwn(OUTGOING_STYLE_RULES, property)) return false
return OUTGOING_STYLE_RULES[property as OutgoingStyleProperty].test(value.trim())
}
// ── 들어오는 style 값 (붙여 넣은 글 · 원문 인용) ─────────────────────────────
const IMPORTANT = /\s*!\s*important\s*$/i
/** px · pt 길이 */
const LENGTH = /^(\d+(?:\.\d+)?)(px|pt)$/i
/** 워드 · 한글이 쓰는 pt → px (1pt = 4/3px) */
const PX_PER_PT = 4 / 3
/** 소수 둘째 자리까지 — 서버 형식이 그 이상을 받지 않는다 */
const roundTo2 = (value: number) => Math.round(value * 100) / 100
/** 값 끝의 `!important` 를 떼고 앞뒤 공백을 지운다 */
export function stripImportant(value: string): string {
return value.replace(IMPORTANT, '').trim()
}
/** px · pt 길이 → px 숫자 (0 이상). 그 밖의 단위(em · rem · % 등)는 기준 크기를 알 수 없어 null */
export function lengthToPx(value: string): number | null {
const match = LENGTH.exec(stripImportant(value))
if (!match?.[1] || !match[2]) return null
const amount = Number(match[1])
return match[2].toLowerCase() === 'pt' ? amount * PX_PER_PT : amount
}
/**
* 들어온 style 값 → 보낼 수 있는 값. 형식이 틀리면 null (그 서식은 받지 않는다).
* - `!important` 는 떼고 본다 (서버도 값만 본다)
* - 글자 크기: pt 는 px 로 바꾸고 소수 셋째 자리부터는 반올림한다 (워드 10pt → 13.33px, 아웃룩의 13.3333px)
*/
export function acceptStyleValue(property: OutgoingStyleProperty, raw: string | null | undefined): string | null {
if (typeof raw !== 'string') return null
let value = stripImportant(raw)
if (property === 'font-size') {
const px = lengthToPx(value)
if (px !== null) value = `${roundTo2(px)}px`
}
return isOutgoingStyleValue(property, value) ? value : null
}
// ── 숫자 속성 (서버 SMALL_NUMBER — 목록 시작 번호 · 칸 합치기) ──────────────────
/** 서버가 남기는 숫자 속성 값 — 숫자 1~4자리 */
const SMALL_NUMBER = /^\d{1,4}$/
/** 칸 합치기 상한 — HTML 의 colspan 상한과 같다 (그보다 크면 브라우저도 1000 으로 본다) */
const CELL_SPAN_MAX = 1000
function smallNumber(value: unknown): number | null {
const text = typeof value === 'number' ? String(value) : value
if (typeof text !== 'string' || !SMALL_NUMBER.test(text.trim())) return null
return Number(text.trim())
}
/** 번호 목록의 시작 번호 — 서버가 남기는 값(0~9999)이 아니면 1 (받는 쪽도 1 부터 보인다) */
export function listStartOf(value: unknown): number {
return smallNumber(value) ?? 1
}
/** 칸 합치기(colspan · rowspan) — 1~1000. 형식이 틀리면 1 (서버가 지우면 받는 쪽도 1 칸이다) */
export function cellSpanOf(value: unknown): number {
const span = smallNumber(value)
return span === null || span < 1 ? 1 : Math.min(span, CELL_SPAN_MAX)
}
+15
View File
@@ -48,6 +48,21 @@ a:hover {
outline-offset: var(--focus-ring-offset);
}
/*
* 글자 입력칸 포커스 — 한 가지 규칙 (디자인 README "메일 쓰기 · 글자 입력칸 포커스")
* 받는 사람·참조·숨은참조 행, 제목 행, 본문 편집 영역에 .compose-field 를 붙인다.
* 쓰는 곳에 커서가 있으면 그 행의 아래 1px 선이 2px 브랜드색이 된다 (선 색 + 안쪽 1px — 높이가 변하지 않는다).
* 행 안의 버튼(토큰 ✕ · "참조 · 숨은참조")에 포커스가 가면 버튼 고리만 보이고 선은 회색 그대로다.
*/
.compose-field:has(:is(input, .ProseMirror):focus) {
border-bottom-color: var(--color-brand);
box-shadow: inset 0 calc(var(--border-width) * -1) 0 var(--color-brand);
}
.compose-field :is(input, .ProseMirror):focus-visible {
outline: none;
}
/* 화면에는 숨기고 스크린리더에는 읽힌다 (안 읽음·준비 중 같은 상태 전달용) */
.visually-hidden {
position: absolute;
+24
View File
@@ -160,4 +160,28 @@
--content-toolbar-start: calc(var(--content-inline) - var(--toolbar-button-inline));
/* 744px — 쓰기 본문 입력칸 = 글줄 680 + 좌우 여백 */
--compose-body-max-width: calc(var(--content-measure) + var(--content-inline) * 2);
/*
* ── 메일 쓰기 서식 편집기 (디자인 README "메일 쓰기", 2026-10-27 확정) ──
* 아래 네 크기는 클릭 영역 44px 기준의 의도된 예외다 — 사용자 확정 (디자인 README "디자인 원칙").
* 툴바 아이콘 버튼 40×44 · 메뉴 항목 높이 40 · 색 견본 칸 40×40 · 표 고르기 칸 22(간격 3, 키보드는 화살표)
*/
--size-editor-tool: 2.5rem; /* 40px — 툴바 아이콘 버튼 폭 (높이는 --size-touch 44px) · 밀도 예외 */
--size-editor-menu-item: 2.5rem; /* 40px — 메뉴 항목 높이 · 밀도 예외 */
--size-editor-swatch-cell: 2.5rem; /* 40px — 색 견본 칸 · 밀도 예외 */
--size-editor-table-cell: 1.375rem; /* 22px — 표 고르기 격자 칸 · 밀도 예외 */
--editor-table-cell-gap: 0.1875rem; /* 3px */
--size-editor-swatch: 1.625rem; /* 26px — 견본 안에 보이는 색 */
--size-editor-font-button: 6.75rem; /* 108px — 글꼴 단추 */
--size-editor-size-button: 3.5rem; /* 56px — 글자 크기 단추 */
--size-editor-toolbar: 3.25rem; /* 52px — 툴바 높이 (위아래 4) */
--size-editor-body-min: 18.75rem; /* 300px — 본문 편집 영역의 최소 높이 (편집기를 불러오는 동안의 자리도 같다) */
--editor-toolbar-inline: 1.25rem; /* 20px — 툴바 좌우 여백. 버튼 안 12 를 더하면 첫 아이콘이 32px 선 */
--color-editor-pressed-hover: #d5ebed; /* 켜진 버튼에 마우스를 올렸을 때 */
--color-quote-bar: #d0d5dd; /* 원문 인용 막대 — 포커스 선(브랜드색)과 겹치지 않게 회색 */
--color-drop-surface: rgba(243, 248, 248, 0.94); /* 끌어다 놓는 동안의 덮개 */
/* 본문 기본 글꼴 — 맑은 고딕 스택. 보내는 HTML 의 글꼴(shared/constants/mail-editor.ts MAIL_BODY_FONT_FAMILY)과 같다 */
--font-family-mail-body: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif;
/* 본문 줄 간격 — 보내는 HTML 의 줄 간격(shared/constants/mail-editor.ts MAIL_BODY_LINE_HEIGHT)과 같다 */
--line-height-mail-body: 1.75;
}
@@ -0,0 +1,118 @@
// 보내는 서식 본문의 style 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다)
// 프론트 값 규칙(shared/utils/outgoing-html-rules.ts)과 서식 편집기가 읽어 들이는 값(editor/mail-format-extensions.ts)을 같은 표로 본다.
/**
* 표본 표 — [속성, 값, 서버가 남기는가].
* ⚠️ 서버 정화 테스트(backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts)와 **같은 표**를 둔다.
* 값 형식을 바꾸면 두 표를 함께 고친다 (값에 큰따옴표를 쓰지 않는다 — style="…" 안에 그대로 넣을 수 있게).
*/
export const STYLE_SAMPLES: readonly (readonly [property: string, value: string, kept: boolean])[] = [
// 색 — #rgb · #rrggbb · rgb()/rgba() 만
['color', '#B42318', true],
['color', '#abc', true],
['color', 'rgb(23, 92, 211)', true],
['color', 'RGB(1,2,3)', true],
['color', 'rgba(0, 0, 0, 0.5)', true],
['color', 'red', false],
['color', 'transparent', false],
['color', 'currentcolor', false],
['color', 'hsl(0, 100%, 50%)', false],
['color', '#12345678', false],
['color', 'rgb(10 20 30)', false],
['color', 'rgba(0, 0, 0, 0.12345)', false],
['color', 'url(https://evil.example/t.png)', false],
['color', 'expression(alert(1))', false],
['color', 'var(--text)', false],
['background-color', '#FFF1A8', true],
['background-color', 'yellow', false],
['background-color', 'transparent', false],
// 글자 크기 — px 만, 1~99.99px
['font-size', '14px', true],
['font-size', '12.5px', true],
['font-size', '13.33px', true],
['font-size', '1px', true],
['font-size', '99.99px', true],
['font-size', '0', false],
['font-size', '0px', false],
['font-size', '0.5px', false],
['font-size', '13.3333px', false],
['font-size', '100px', false],
['font-size', '1.2em', false],
['font-size', '1rem', false],
['font-size', '120%', false],
['font-size', 'large', false],
['font-size', '-12px', false],
['font-size', '12pt', false],
// 글꼴 — 목록의 이름으로만 이뤄진 목록
['font-family', 'Arial', true],
['font-family', "'Times New Roman', Times, serif", true],
['font-family', '굴림, Gulim, sans-serif', true],
['font-family', "'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif", true],
['font-family', 'monospace', true],
['font-family', 'Calibri, sans-serif', false],
['font-family', "'Comic Sans MS'", false],
['font-family', "Arial, 'Comic Sans MS'", false],
['font-family', 'Roboto', false],
['font-family', "'Arial Black'", false],
// 줄 간격 — 단위 없는 1~3
['line-height', '1', true],
['line-height', '1.75', true],
['line-height', '3', true],
['line-height', '3.00', true],
['line-height', '30px', false],
['line-height', '150%', false],
['line-height', '1.5em', false],
['line-height', 'normal', false],
['line-height', '0.9', false],
['line-height', '3.5', false],
['line-height', '1.755', false],
// 정렬 · 굵기
['text-align', 'left', true],
['text-align', 'center', true],
['text-align', 'justify', true],
['text-align', 'middle', false],
['text-align', 'start', false],
['font-weight', 'bold', true],
['font-weight', '700', true],
['font-weight', 'bolder', false],
// 여백 · 들여쓰기 — 0 또는 px (음수 없음)
['margin-left', '0', true],
['margin-left', '40px', true],
['margin-left', '320px', true],
['margin-left', '-10px', false],
['margin-left', '2em', false],
['margin-left', '5%', false],
['margin-left', '1000px', false],
['margin', '0', true],
['margin', '24px 0 0', true],
['margin', '0 auto', false],
['padding-left', '14px', true],
['padding-left', '5%', false],
// 표 — 칸 폭 · 테두리 · 세로 정렬 · 줄바꿈 막기
['width', '120px', true],
['width', '50%', true],
['width', 'auto', true],
['width', '1000px', false],
['width', '-5px', false],
['min-width', '25px', true],
['border', '1px solid #D0D5DD', true],
['border', '1px solid rgb(208, 213, 219)', true],
['border', '0', true],
['border', '1px dotted red', false],
['border-left', '2px solid #D0D5DD', true],
['border-collapse', 'collapse', true],
['border-collapse', 'inherit', false],
['vertical-align', 'top', true],
['vertical-align', 'sub', false],
['white-space', 'nowrap', true],
['white-space', 'normal', true],
['white-space', 'pre-wrap', false],
['white-space', 'pre', false],
// 목록에 없는 속성 — 값과 관계없이 지운다
['display', 'none', false],
['visibility', 'hidden', false],
['opacity', '0', false],
['position', 'absolute', false],
['text-decoration', 'underline', false],
]
@@ -0,0 +1,71 @@
// 단위 테스트 전용 — jsdom 에 없는 DOM 기능 중 ProseMirror(서식 편집기)가 부르는 것만 채운다.
// jsdom 은 화면 배치를 하지 않으므로 위치·크기는 모두 0 이다 (커서 위치 계산·스크롤이 오류 없이 지나가게만 한다).
// 사용: beforeAll(installProseMirrorDom). 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
import type { Editor } from '@tiptap/core'
const EMPTY_RECT: DOMRect = {
x: 0,
y: 0,
top: 0,
left: 0,
right: 0,
bottom: 0,
width: 0,
height: 0,
toJSON: () => ({}),
}
function emptyRectList(): DOMRectList {
const list = [] as unknown as DOMRectList & DOMRect[]
Object.defineProperty(list, 'item', { value: () => null })
return list
}
export function installProseMirrorDom(): void {
if (!('getClientRects' in Range.prototype)) {
Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: emptyRectList })
}
if (!('getBoundingClientRect' in Range.prototype)) {
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
configurable: true,
value: () => EMPTY_RECT,
})
}
if (!('elementFromPoint' in document)) {
Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => null })
}
}
/** 화면의 편집 영역에서 TipTap 편집기를 꺼낸다 (TipTap 이 view.dom.editor 에 붙여 둔다) */
export function editorOf(root: Element | null): Editor {
const dom = root?.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null
if (!dom?.editor) throw new Error('편집기가 아직 없다')
return dom.editor
}
/** 붙여 넣기 이벤트 — jsdom 에는 DataTransfer 가 없어 필요한 부분만 흉내 낸다 */
export function pasteEvent(data: { files?: File[]; text?: string; html?: string }): Event {
const event = new Event('paste', { bubbles: true, cancelable: true })
const values: Record<string, string> = { 'text/plain': data.text ?? '', 'text/html': data.html ?? '' }
const files = data.files ?? []
Object.defineProperty(event, 'clipboardData', {
value: {
files,
types: [...(files.length > 0 ? ['Files'] : []), ...Object.keys(values).filter((key) => values[key])],
getData: (type: string) => values[type] ?? '',
},
})
return event
}
/** 끌어다 놓기 이벤트 (dragenter · dragover · dragleave · drop) — 파일 끌기만 */
export function fileDragEvent(type: string, files: File[] = [], init: { relatedTarget?: EventTarget | null } = {}): Event {
const event = new Event(type, { bubbles: true, cancelable: true })
Object.defineProperty(event, 'dataTransfer', {
value: { files, types: ['Files'], dropEffect: 'none', getData: () => '' },
})
Object.defineProperty(event, 'relatedTarget', { value: init.relatedTarget ?? document.body })
Object.defineProperty(event, 'clientX', { value: 0 })
Object.defineProperty(event, 'clientY', { value: 0 })
return event
}