generated from ttipo/project-base
feat(frontend): 메일 쓰기 서식 편집기와 입력칸 포커스 통일
메일 쓰기 화면은 입력칸마다 포커스 모양이 달랐고, 본문은 서식 없는 글만 쓸 수 있었다. - 글자 입력 행(받는 사람·참조·숨은참조·제목·본문)의 포커스를 아래 선 하나(.compose-field)로 통일했다 - 본문을 TipTap 서식 편집기로 바꿨다 — 글꼴·크기·굵게·색·목록·링크·정렬·들여쓰기·인용·표·가로줄, 단축키, 좁은 폭에서 묶음째 접는 툴바. 편집기는 쓰기 화면에서 따로 불러온다(지연 로딩) - 보낼 때는 기본 글꼴(맑은 고딕) div 와 인라인 style 의 HTML 에 텍스트 대체 본문을 함께 보낸다 (여러 칸 공백은 nbsp, 탭은 8칸 자리까지) - 붙여 넣은 글·원문 인용은 서버 정화와 같은 값 형식만 읽는다 — 편집기에서 숨은 글이 받는 사람에게 보이지 않게 - 답장·전달 인용은 "원본 메일" 머리와 함께 서식째 옮긴다. 초안은 내 글 몫 5%를 남기고, 보내기 직전에 다시 재서 인용을 줄인다 - 본문에 붙여 넣거나 끌어다 놓은 이미지·파일은 첨부로 올린다 — 화면 어디에 놓아도 (브라우저가 파일을 열어 쓰던 메일을 잃지 않게) 의존성: @tiptap/* 3.31.4 (MIT 무료 패키지만, 모두 같은 판으로 고정) Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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"> 1. 일시 : 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, ' '),
|
||||
)
|
||||
// 서식 경계를 넘어 이어진 공백 · 줄바꿈(<br>) 뒤의 줄 앞 공백
|
||||
expect(toMailHtmlFragment('<p>a <strong> b</strong><br> c</p>')).toBe(
|
||||
'<p style="margin: 0">a <strong> b</strong><br> c</p>',
|
||||
)
|
||||
// 탭은 다음 탭 칸(8칸)까지의 공백 — 줄 앞이면 8칸, "ab" 뒤면 6칸
|
||||
expect(toMailHtmlFragment('<p>\tx</p><p>ab\ty</p>')).toBe(
|
||||
'<p style="margin: 0"> x</p><p style="margin: 0">ab 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 }
|
||||
}
|
||||
Reference in New Issue
Block a user