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,118 @@
|
||||
// 보내는 서식 본문의 style 표본 — 단위 테스트 전용 (앱 코드에서 import 하지 않는다)
|
||||
// 프론트 값 규칙(shared/utils/outgoing-html-rules.ts)과 서식 편집기가 읽어 들이는 값(editor/mail-format-extensions.ts)을 같은 표로 본다.
|
||||
|
||||
/**
|
||||
* 표본 표 — [속성, 값, 서버가 남기는가].
|
||||
* ⚠️ 서버 정화 테스트(backend/src/modules/mail/html/sanitize-outgoing-html.spec.ts)와 **같은 표**를 둔다.
|
||||
* 값 형식을 바꾸면 두 표를 함께 고친다 (값에 큰따옴표를 쓰지 않는다 — style="…" 안에 그대로 넣을 수 있게).
|
||||
*/
|
||||
export const STYLE_SAMPLES: readonly (readonly [property: string, value: string, kept: boolean])[] = [
|
||||
// 색 — #rgb · #rrggbb · rgb()/rgba() 만
|
||||
['color', '#B42318', true],
|
||||
['color', '#abc', true],
|
||||
['color', 'rgb(23, 92, 211)', true],
|
||||
['color', 'RGB(1,2,3)', true],
|
||||
['color', 'rgba(0, 0, 0, 0.5)', true],
|
||||
['color', 'red', false],
|
||||
['color', 'transparent', false],
|
||||
['color', 'currentcolor', false],
|
||||
['color', 'hsl(0, 100%, 50%)', false],
|
||||
['color', '#12345678', false],
|
||||
['color', 'rgb(10 20 30)', false],
|
||||
['color', 'rgba(0, 0, 0, 0.12345)', false],
|
||||
['color', 'url(https://evil.example/t.png)', false],
|
||||
['color', 'expression(alert(1))', false],
|
||||
['color', 'var(--text)', false],
|
||||
['background-color', '#FFF1A8', true],
|
||||
['background-color', 'yellow', false],
|
||||
['background-color', 'transparent', false],
|
||||
// 글자 크기 — px 만, 1~99.99px
|
||||
['font-size', '14px', true],
|
||||
['font-size', '12.5px', true],
|
||||
['font-size', '13.33px', true],
|
||||
['font-size', '1px', true],
|
||||
['font-size', '99.99px', true],
|
||||
['font-size', '0', false],
|
||||
['font-size', '0px', false],
|
||||
['font-size', '0.5px', false],
|
||||
['font-size', '13.3333px', false],
|
||||
['font-size', '100px', false],
|
||||
['font-size', '1.2em', false],
|
||||
['font-size', '1rem', false],
|
||||
['font-size', '120%', false],
|
||||
['font-size', 'large', false],
|
||||
['font-size', '-12px', false],
|
||||
['font-size', '12pt', false],
|
||||
// 글꼴 — 목록의 이름으로만 이뤄진 목록
|
||||
['font-family', 'Arial', true],
|
||||
['font-family', "'Times New Roman', Times, serif", true],
|
||||
['font-family', '굴림, Gulim, sans-serif', true],
|
||||
['font-family', "'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif", true],
|
||||
['font-family', 'monospace', true],
|
||||
['font-family', 'Calibri, sans-serif', false],
|
||||
['font-family', "'Comic Sans MS'", false],
|
||||
['font-family', "Arial, 'Comic Sans MS'", false],
|
||||
['font-family', 'Roboto', false],
|
||||
['font-family', "'Arial Black'", false],
|
||||
// 줄 간격 — 단위 없는 1~3
|
||||
['line-height', '1', true],
|
||||
['line-height', '1.75', true],
|
||||
['line-height', '3', true],
|
||||
['line-height', '3.00', true],
|
||||
['line-height', '30px', false],
|
||||
['line-height', '150%', false],
|
||||
['line-height', '1.5em', false],
|
||||
['line-height', 'normal', false],
|
||||
['line-height', '0.9', false],
|
||||
['line-height', '3.5', false],
|
||||
['line-height', '1.755', false],
|
||||
// 정렬 · 굵기
|
||||
['text-align', 'left', true],
|
||||
['text-align', 'center', true],
|
||||
['text-align', 'justify', true],
|
||||
['text-align', 'middle', false],
|
||||
['text-align', 'start', false],
|
||||
['font-weight', 'bold', true],
|
||||
['font-weight', '700', true],
|
||||
['font-weight', 'bolder', false],
|
||||
// 여백 · 들여쓰기 — 0 또는 px (음수 없음)
|
||||
['margin-left', '0', true],
|
||||
['margin-left', '40px', true],
|
||||
['margin-left', '320px', true],
|
||||
['margin-left', '-10px', false],
|
||||
['margin-left', '2em', false],
|
||||
['margin-left', '5%', false],
|
||||
['margin-left', '1000px', false],
|
||||
['margin', '0', true],
|
||||
['margin', '24px 0 0', true],
|
||||
['margin', '0 auto', false],
|
||||
['padding-left', '14px', true],
|
||||
['padding-left', '5%', false],
|
||||
// 표 — 칸 폭 · 테두리 · 세로 정렬 · 줄바꿈 막기
|
||||
['width', '120px', true],
|
||||
['width', '50%', true],
|
||||
['width', 'auto', true],
|
||||
['width', '1000px', false],
|
||||
['width', '-5px', false],
|
||||
['min-width', '25px', true],
|
||||
['border', '1px solid #D0D5DD', true],
|
||||
['border', '1px solid rgb(208, 213, 219)', true],
|
||||
['border', '0', true],
|
||||
['border', '1px dotted red', false],
|
||||
['border-left', '2px solid #D0D5DD', true],
|
||||
['border-collapse', 'collapse', true],
|
||||
['border-collapse', 'inherit', false],
|
||||
['vertical-align', 'top', true],
|
||||
['vertical-align', 'sub', false],
|
||||
['white-space', 'nowrap', true],
|
||||
['white-space', 'normal', true],
|
||||
['white-space', 'pre-wrap', false],
|
||||
['white-space', 'pre', false],
|
||||
// 목록에 없는 속성 — 값과 관계없이 지운다
|
||||
['display', 'none', false],
|
||||
['visibility', 'hidden', false],
|
||||
['opacity', '0', false],
|
||||
['position', 'absolute', false],
|
||||
['text-decoration', 'underline', false],
|
||||
]
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
// 단위 테스트 전용 — jsdom 에 없는 DOM 기능 중 ProseMirror(서식 편집기)가 부르는 것만 채운다.
|
||||
// jsdom 은 화면 배치를 하지 않으므로 위치·크기는 모두 0 이다 (커서 위치 계산·스크롤이 오류 없이 지나가게만 한다).
|
||||
// 사용: beforeAll(installProseMirrorDom). 앱 코드에서 import 하지 않는다 (번들에 들어가지 않는다).
|
||||
import type { Editor } from '@tiptap/core'
|
||||
|
||||
const EMPTY_RECT: DOMRect = {
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
left: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
toJSON: () => ({}),
|
||||
}
|
||||
|
||||
function emptyRectList(): DOMRectList {
|
||||
const list = [] as unknown as DOMRectList & DOMRect[]
|
||||
Object.defineProperty(list, 'item', { value: () => null })
|
||||
return list
|
||||
}
|
||||
|
||||
export function installProseMirrorDom(): void {
|
||||
if (!('getClientRects' in Range.prototype)) {
|
||||
Object.defineProperty(Range.prototype, 'getClientRects', { configurable: true, value: emptyRectList })
|
||||
}
|
||||
if (!('getBoundingClientRect' in Range.prototype)) {
|
||||
Object.defineProperty(Range.prototype, 'getBoundingClientRect', {
|
||||
configurable: true,
|
||||
value: () => EMPTY_RECT,
|
||||
})
|
||||
}
|
||||
if (!('elementFromPoint' in document)) {
|
||||
Object.defineProperty(document, 'elementFromPoint', { configurable: true, value: () => null })
|
||||
}
|
||||
}
|
||||
|
||||
/** 화면의 편집 영역에서 TipTap 편집기를 꺼낸다 (TipTap 이 view.dom.editor 에 붙여 둔다) */
|
||||
export function editorOf(root: Element | null): Editor {
|
||||
const dom = root?.querySelector('.ProseMirror') as (HTMLElement & { editor?: Editor }) | null
|
||||
if (!dom?.editor) throw new Error('편집기가 아직 없다')
|
||||
return dom.editor
|
||||
}
|
||||
|
||||
/** 붙여 넣기 이벤트 — jsdom 에는 DataTransfer 가 없어 필요한 부분만 흉내 낸다 */
|
||||
export function pasteEvent(data: { files?: File[]; text?: string; html?: string }): Event {
|
||||
const event = new Event('paste', { bubbles: true, cancelable: true })
|
||||
const values: Record<string, string> = { 'text/plain': data.text ?? '', 'text/html': data.html ?? '' }
|
||||
const files = data.files ?? []
|
||||
Object.defineProperty(event, 'clipboardData', {
|
||||
value: {
|
||||
files,
|
||||
types: [...(files.length > 0 ? ['Files'] : []), ...Object.keys(values).filter((key) => values[key])],
|
||||
getData: (type: string) => values[type] ?? '',
|
||||
},
|
||||
})
|
||||
return event
|
||||
}
|
||||
|
||||
/** 끌어다 놓기 이벤트 (dragenter · dragover · dragleave · drop) — 파일 끌기만 */
|
||||
export function fileDragEvent(type: string, files: File[] = [], init: { relatedTarget?: EventTarget | null } = {}): Event {
|
||||
const event = new Event(type, { bubbles: true, cancelable: true })
|
||||
Object.defineProperty(event, 'dataTransfer', {
|
||||
value: { files, types: ['Files'], dropEffect: 'none', getData: () => '' },
|
||||
})
|
||||
Object.defineProperty(event, 'relatedTarget', { value: init.relatedTarget ?? document.body })
|
||||
Object.defineProperty(event, 'clientX', { value: 0 })
|
||||
Object.defineProperty(event, 'clientY', { value: 0 })
|
||||
return event
|
||||
}
|
||||
Reference in New Issue
Block a user