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 }
}