fix(frontend): 16단계 실사용 시험에서 나온 둘을 고친다
CI / Frontend (lint · type-check · test · build) (pull_request) Successful in 13m52s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (pull_request) Successful in 2m4s
CI / Backend (lint · type-check · test · e2e · build) (pull_request) Successful in 13m39s
CI / 에러 코드 SSOT 동기화 검사 (pull_request) Successful in 11m53s
CI / Backend (lint · type-check · test · e2e · build) (push) Successful in 14m39s
CI / Frontend (lint · type-check · test · build) (push) Successful in 14m11s
CI / Infra (compose · Dockerfile · nginx · 운영 스크립트) (push) Successful in 2m4s
CI / 에러 코드 SSOT 동기화 검사 (push) Successful in 11m34s

- 주소록 가져오기 창의 미리 보기가 1366×768 · 1440×900 에서 잘리고 창도 스크롤하지 않았다 — 창(<dialog>)은 flex 열이라 overflow 를 건 세 묶음 상자가 내용보다 작게 눌렸다. 상자에 flex-shrink: 0 을 두고 가져오기 · 내보내기 창은 창 안이 스크롤한다
- 서명 위 빈 줄에 한글을 조합해 쓰면 띄어쓰기를 칠 때까지 빈 줄이 돌아오지 않았다 — 조합이 끝나면(compositionend 뒤 ProseMirror 가 조합을 마무리한 다음 차례) 편집기가 스스로 빈 줄을 넣는다. 조합 중에 임시 저장한 뒤 편집기가 넣은 빈 줄은 바꿈으로 세지 않는다
- 디자인 README · vue3 스킬에 두 가지를 적는다(모달 창의 flex 열 — 창 바로 아래 칸에 overflow: hidden 을 걸면 flex-shrink: 0 을 함께)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit was merged in pull request #25.
This commit is contained in:
2026-11-10 13:30:08 +09:00
parent 19dbb2babb
commit d6b804ba83
10 changed files with 274 additions and 16 deletions
File diff suppressed because one or more lines are too long
+4 -1
View File
@@ -1316,6 +1316,8 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
- 남은 자리보다 많으면 노란 상자 "연락처는 3,000개까지 둘 수 있어 앞에서부터 188명만 넣습니다. 나머지 142명은 넣지 않습니다." — 단추의 수도 그만큼.
- [연락처 330명 넣기](처음 초점) [취소]. 넣을 연락처가 없으면 [연락처 넣기]를 끄고(`aria-disabled`) 단추 왼쪽에 "넣을 연락처가 없습니다."
- Esc · [취소]는 묻지 않고 닫는다(아직 아무것도 넣지 않았다). 창이 화면보다 길면 창 안이 스크롤하고 단추 줄은 고정하지 않는다(필터 창과 같다).
창 안의 칸은 줄어들지 않는다 — 세 묶음 상자(둥근 모서리 때문에 `overflow: hidden`)는 `flex-shrink: 0`. 창(`<dialog>`)이 flex 열이라 overflow 를 건 칸은 내용보다 작게 눌릴 수 있다
(2026-11-10 실사용 시험 — 1366×768 · 1440×900 에서 상자가 눌려 [이미 있는 연락처는 건너뜁니다] · "그 밖에 N명"이 안 보이고 창도 스크롤하지 않았다). 내보내기 창도 같다.
- **넣는 동안 · 끝난 뒤**(알림 — 필터 적용 · 차단 정리와 같은 결): [연락처 N명 넣기]를 누르면 창을 닫고 진행 알림 "연락처를 넣는 중 · 120 / 330명"(넣은 수 / 넣을 수 — 아래 「요청 묶음」 하나가 끝날 때마다 고친다).
- 끝: 같은 자리에서 "연락처 330명을 넣었습니다. 이미 있던 8명은 건너뛰었습니다." + [되돌리기] + ✕ — **✕로 닫을 때까지 남는다**(저절로 닫히지 않는다).
건너뛴 수 · 한도에 걸린 수는 한 문장씩 덧붙인다 — "연락처는 3,000개까지 둘 수 있어 나머지 142명은 넣지 못했습니다." · "그룹은 100개, 그룹 하나에는 500명까지 둘 수 있어 24명은 그룹에 넣지 못했습니다."
@@ -1973,7 +1975,8 @@ PC 에서는 설정 › 계정 › 휴대폰 앱(A)이 QR 코드로 휴대폰에
쓰기 화면이 처음 넣는 서명은 표시(`data-mail-signature` — 서버 정화가 지운다)로 안다.
- **서명 앞의 빈 줄은 늘 둔다**(2026-11-10 리뷰 M3 — 빈 줄에 글을 쓰거나 지운 채 저장하면 다시 열 때 서명 자리를 잃었다): 그 빈 줄에 글을 쓰면 쓴 글 아래 · 서명 위에 새 빈 줄이 생기고,
Backspace · Delete 로 빈 줄을 지우면 다시 생긴다(커서는 쓰던 자리 · 윗줄 끝, 실행 취소는 그 입력과 함께 — 쓴 글은 지우지 않는다).
한글을 조합하는 중에는 미뤘다가 다음 입력에서 넣고, 보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다.
한글을 조합하는 중에는 미뤘다가 조합이 끝나면(화살표 · 다른 칸 누르기 등 — 다른 입력이 없어도) 넣고(2026-11-10 실사용 시험 — 띄어쓰기를 쳐야 돌아왔다),
보내고 저장하는 모양도 빠진 빈 줄을 한 번 더 넣는다. 조합 중에 저장한 뒤 편집기가 넣은 빈 줄은 바꿈으로 세지 않는다(저장한 본문과 같다).
알려진 한계: 16단계 앞에 저장한 본문(서명이 문단으로만 들어 있다)은 서명 자리를 찾지 못한다 — 그 서명은 글로 남고, [서명 ▾]는 ✓ 없이 열리며 고르면 처음 넣는 자리에 새로 넣는다.
- **서명 자리는 하나**: 서명 안에서 복사해 붙여 넣으면 편집기가 서명 자리째 붙이는데, 그때 처음부터 있던 것만 두고 나머지는 글로 푼다(쓰기 화면의 편집기에서만 — 붙여 넣기 · 끌어 놓기 모두).
- **어느 서명이 들었나**: 서명 자리의 글(공백 · nbsp 를 하나로 줄여)과 그림 주소를 저장한 서명(편집기로 읽은 모양)과 견준다 — 만든 차례로 먼저 맞는 서명에 ✓. 본문이 바뀔 때마다 다시 잰다(서명 안을 고치면 ✓ 가 빠진다).
@@ -97,6 +97,7 @@ watch(
<template>
<ModalDialog
id="contact-export-dialog"
class="export-dialog"
:open="open"
:title="CONTACT_EXPORT_TEXT.title"
title-id="contact-export-title"
@@ -225,6 +226,11 @@ watch(
</template>
<style scoped>
/* 창이 화면보다 길면 창 안이 스크롤한다 — 가져오기 창과 같다(창 바로 아래 칸은 줄어들지 않게 overflow 를 걸지 않는다) */
.export-dialog {
overflow-y: auto;
}
.export-dialog__fieldset {
display: flex;
flex-direction: column;
@@ -246,6 +246,7 @@ onBeforeUnmount(stopReading)
<template>
<ModalDialog
id="contact-import-dialog"
class="import-dialog"
:open="open"
:title="CONTACT_IMPORT_TEXT.title"
:message="step === 'preview' ? '' : CONTACT_IMPORT_TEXT.message"
@@ -371,6 +372,14 @@ onBeforeUnmount(stopReading)
</template>
<style scoped>
/*
* 창이 화면보다 길면 창 안이 스크롤한다 — 단추 줄은 고정하지 않는다(필터 창과 같다 · 브라우저의 dialog:modal 기본값을 적어 둔다).
* 그래서 창 바로 아래 칸은 줄어들지 않아야 한다 — overflow 를 건 칸은 flex-shrink: 0 을 함께 둔다(세 묶음 상자 — ContactImportSummary)
*/
.import-dialog {
overflow-y: auto;
}
/* 파일 줄 — 아이콘 · 이름(줄임) · [다른 파일 고르기] (위 글과 붙여 둔다) */
.import-dialog__file {
display: flex;
@@ -206,9 +206,15 @@ const problems = computed(() =>
</template>
<style scoped>
/*
* 창(<dialog> — flex 열) 안에서 줄어들지 않는다 — overflow 를 건 칸은 최소 높이가 0 이 되어 창이 넘치는 대신 이 상자가 눌려 잘렸다
* (2026-11-10 실사용 시험 — 1366×768 · 1440×900 에서 [이미 있는 연락처는 건너뜁니다] · "그 밖에 N명"이 안 보이고 창도 스크롤하지 않았다).
* 창이 화면보다 길면 창 안이 스크롤한다(ContactImportDialog). overflow 는 둥근 모서리 밖으로 펼치기 줄의 바탕이 나오지 않게 둔다
*/
.import-summary {
display: flex;
flex-direction: column;
flex-shrink: 0;
border: var(--border-width) solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
@@ -0,0 +1,64 @@
import { describe, expect, it } from 'vitest'
import exportSource from './ContactExportDialog.vue?raw'
import importSource from './ContactImportDialog.vue?raw'
import groupsSource from './ContactImportGroupFields.vue?raw'
import infoSource from './ContactImportInfoField.vue?raw'
import pickerSource from './ContactImportPicker.vue?raw'
import summarySource from './ContactImportSummary.vue?raw'
// 가져오기 · 내보내기 창의 높이 (16단계 실사용 시험 2026-11-10 — 1366×768 · 1440×900 에서 미리 보기의 세 묶음 상자가 눌려 잘리고 창도 스크롤하지 않았다)
// 창(<dialog>)은 flex 열이다. 화면보다 길면 창 안이 스크롤한다(단추 줄 고정 없음 — 필터 창과 같다). 그런데 overflow 를 건 칸은 flex 열에서
// 최소 높이가 0 이 되어 창이 넘치는 대신 그 칸이 눌린다 — 그런 칸은 flex-shrink: 0 을 함께 둔다.
// jsdom 은 크기를 재지 않는다 — 원본의 style 을 읽는다 (AppTopBar.spec · MessageReader.stage15.spec 과 같은 읽기)
/** 컴포넌트 원본에서 한 규칙의 본문 (주석은 뺀다) */
function ruleBody(source: string, selector: string): string {
const rules = source
.slice(source.indexOf('<style'))
.replace(/\/\*[\s\S]*?\*\//g, '')
.matchAll(/([^{}]+)\{([^}]*)\}/g)
for (const [, found = '', body = ''] of rules) {
if (found.replace('<style scoped>', '').trim() === selector) return body
}
return ''
}
/** 칸을 잘라 내는 overflow (overflow-wrap 은 아니다) */
const CLIPPING_OVERFLOW = /(?:^|[\s;])overflow(?:-[xy])?:\s*(?:hidden|auto|scroll|clip)\b/
describe('가져오기 · 내보내기 창 — 창 안이 스크롤하고 칸은 줄어들지 않는다', () => {
it('창은 overflow-y: auto, 창 바로 아래 칸 가운데 overflow 를 건 칸(세 묶음 상자)은 flex-shrink: 0 · 펼친 목록은 6줄 높이에서 안쪽 스크롤', () => {
expect(importSource).toMatch(/<ModalDialog\s+id="contact-import-dialog"\s+class="import-dialog"/)
expect(exportSource).toMatch(/<ModalDialog\s+id="contact-export-dialog"\s+class="export-dialog"/)
expect(ruleBody(importSource, '.import-dialog')).toMatch(/overflow-y:\s*auto;/)
expect(ruleBody(exportSource, '.export-dialog')).toMatch(/overflow-y:\s*auto;/)
const summary = ruleBody(summarySource, '.import-summary')
expect(summary).toMatch(/overflow:\s*hidden;/)
expect(summary).toMatch(/flex-shrink:\s*0;/)
// 창 바로 아래 칸 — 파일 고르기 · 미리 보기(파일 줄 · 찾은 수 · 세 묶음 · 그룹 · 메모 칸 · 한도 안내) · 내보내기
const children: readonly (readonly [string, string])[] = [
[pickerSource, '.import-picker'],
[importSource, '.import-dialog__file'],
[importSource, '.import-dialog__found'],
[summarySource, '.import-summary'],
[groupsSource, '.import-groups'],
[infoSource, '.import-info'],
[importSource, '.import-dialog__limit'],
[exportSource, '.export-dialog__fieldset'],
[exportSource, '.export-dialog__file'],
]
for (const [source, selector] of children) {
const body = ruleBody(source, selector)
expect(body, selector).not.toBe('')
if (CLIPPING_OVERFLOW.test(body)) expect(body, selector).toMatch(/flex-shrink:\s*0;/)
}
// 펼친 두 묶음 목록 — 6줄 높이(186)까지, 넘으면 목록 안에서 넘긴다
const list = ruleBody(summarySource, '.import-summary__list')
expect(list).toMatch(/max-height:\s*11\.625rem;/)
expect(list).toMatch(/overflow-y:\s*auto;/)
})
})
@@ -1,5 +1,6 @@
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { TextSelection } from '@tiptap/pm/state'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { mount, type VueWrapper } from '@vue/test-utils'
import MailEditor from '@/components/mail/editor/MailEditor.vue'
@@ -10,6 +11,7 @@ import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
import { LONG_DRAFT_TIMEOUT_MS } from '@/test-utils/timeouts'
import { buildQuoteHtml, textToParagraphHtml } from '@/shared/utils/mail-quote'
import { newMailHtmlWithSignature } from '@/shared/utils/mail-signature'
type EditorWrapper = VueWrapper<InstanceType<typeof MailEditor>>
@@ -36,6 +38,7 @@ describe('MailEditor (서식 편집기)', () => {
afterEach(() => {
wrapper?.unmount()
wrapper = null
vi.useRealTimers()
})
function mountEditor(props: Record<string, unknown> = {}): EditorWrapper {
@@ -353,6 +356,37 @@ describe('MailEditor (서식 편집기)', () => {
expect(view.vm.isChanged()).toBe(false)
})
it('한글 조합 중에 저장한 뒤 조합이 끝나 편집기가 서명 앞 빈 줄을 다시 넣어도 바꿈이 아니다 — 저장한 본문과 같다 (16단계)', () => {
const view = mountEditor({ initialHtml: newMailHtmlWithSignature('<p>김아라 · 인프라팀</p>') })
const editor = editorOf(view.element)
/** 맨 위 단계 블록 — 서명 자리는 [서명], 문단은 글 */
const blocks = (): string[] => {
const out: string[] = []
editor.state.doc.forEach((child) => out.push(child.type.name === 'mailSignature' ? '[서명]' : child.textContent))
return out
}
// 서명 앞 빈 줄(맨 위 단계 두 번째 문단)에서 조합한다 — 조합 중에는 빈 줄을 미룬다
const blankLine = editor.state.doc.child(0).nodeSize + 1
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, blankLine)))
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
editor.view.dom.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
editor.view.dispatch(editor.state.tr.insertText('한').setMeta('composition', 1))
expect(blocks().slice(0, 3)).toEqual(['', '한', '[서명]'])
// 조합하는 동안 자동 저장 — 저장하는 모양에는 빈 줄이 든다
const snapshot = view.vm.captureForSave()
if (!snapshot) throw new Error('저장할 본문이 없다')
view.vm.markSaved(snapshot.savePoint)
expect(view.vm.isChanged()).toBe(false)
editor.view.dom.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '한' }))
vi.runOnlyPendingTimers()
expect(blocks().slice(0, 4)).toEqual(['', '한', '', '[서명]'])
expect(view.vm.getContent()).toEqual(snapshot.content)
expect(view.vm.isChanged()).toBe(false)
})
it('서명 그림은 본문에 남고, 글자 없이 그림만 있어도 서식 본문으로 보낸다 (텍스트 대체 본문은 빈 글)', () => {
const view = mountEditor({ initialHtml: `<p>${SIGNATURE_IMAGE}</p>` })
@@ -87,7 +87,8 @@ let mounted = false
* 편집기를 다 연 때의 문서(답장 · 전달 초안은 맞춘 뒤) — 쓰기 화면이 "본문을 바꿨는가"를 이것과 비교한다 (나가기 전 확인).
* settled: 같은 문서에 편집기가 스스로 끝 빈 줄을 붙인 모양 — 끝이 인용처럼 문단이 아니면 첫 거래(초점 옮기기 등) 때
* 붙인다(StarterKit 의 TrailingNode). 사용자가 쓴 것이 아니므로 "바꿈"이 아니다.
* 임시 저장이 끝나면 저장한 문서로 바꾼다 (markSaved — 이미 거래를 거친 문서라 settled 도 같다).
* 임시 저장이 끝나면 저장한 문서로 바꾼다 (markSaved — 이미 거래를 거친 문서라 끝 빈 줄은 붙어 있다. 다만 한글 조합 중에 저장했으면
* 서명 자리 앞 빈 줄이 빠져 있을 수 있다 — 조합이 끝나면 편집기가 스스로 넣으므로 settled 에 넣어 둔다, mail-signature-block.ts).
*/
let opened: { doc: ProseMirrorNode; settled: ProseMirrorNode } | null = null
@@ -243,7 +244,7 @@ function captureForSave(): MailEditorSnapshot | null {
/** 임시 저장이 끝났다 — 그때의 문서를 기준으로 삼는다 */
function markSaved(savePoint: EditorSavePoint) {
const doc = savePoint as unknown as ProseMirrorNode
opened = { doc, settled: doc }
opened = { doc, settled: withSignatureBoundary(doc) }
}
/** 본문 안 서명 자리의 비교 값 — 없으면 null (16단계 [서명 ▾], mail-signature-block.ts) */
@@ -1,7 +1,7 @@
import { Editor, createDocument, getHTMLFromFragment, getSchema } from '@tiptap/core'
import type { Node as ProseMirrorNode } from '@tiptap/pm/model'
import { EditorState, TextSelection } from '@tiptap/pm/state'
import { afterEach, beforeAll, describe, expect, it } from 'vitest'
import { afterEach, beforeAll, describe, expect, it, vi } from 'vitest'
import { createMailEditorExtensions } from '@/components/mail/editor/mail-extensions'
import { docToText, normalizeIncomingHtml, toMailHtml } from '@/components/mail/editor/mail-serialize'
@@ -178,7 +178,8 @@ describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-1
beforeAll(installProseMirrorDom)
afterEach(() => {
for (const editor of editors.splice(0)) editor.destroy()
for (const editor of editors.splice(0)) if (!editor.isDestroyed) editor.destroy()
vi.useRealTimers()
})
/** 쓰기 화면의 편집기와 같게 — 플러그인(서명 자리 지키기)은 화면에 붙일 때 켜진다 */
@@ -196,6 +197,20 @@ describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-1
editor.view.dispatch(editor.state.tr.setSelection(TextSelection.create(editor.state.doc, pos + 1)))
}
/**
* 한글 조합 — 브라우저처럼 편집 영역에 조합 시작을 알리고, 조합한 글은 조합 표시를 단 거래로 넣는다
* (ProseMirror 가 조합 중에 DOM 에서 읽어 보내는 거래와 같은 표시)
*/
function compose(editor: Editor, text: string, id: number) {
editor.view.dom.dispatchEvent(new CompositionEvent('compositionstart', { bubbles: true }))
editor.view.dispatch(editor.state.tr.insertText(text).setMeta('composition', id))
}
/** 조합을 마친다 — 화살표 · 다른 칸 누르기 등. 문서는 바뀌지 않는다(조합한 글은 이미 들어갔다) */
function endComposition(editor: Editor, data: string) {
editor.view.dom.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data }))
}
/** 저장하고 다시 연 모양 — 보내는 모양(빈 줄을 지킨 문서 → toMailHtml)에서 서버 정화처럼 data-* 를 지우고 다시 읽는다 */
function reopened(editor: Editor): ProseMirrorNode {
const html = toMailHtml(getHTMLFromFragment(withSignatureBoundary(editor.state.doc).content, schema))
@@ -241,7 +256,7 @@ describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-1
expect(blocks(reopened(editor)).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
})
it('한글을 조합하는 중에는 미룬다 — 보내고 저장하는 모양(withSignatureBoundary)이 채우고, 조합이 끝난 다음 입력에서 편집기도 채운다', () => {
it('한글을 조합하는 중에는 미룬다 — 보내고 저장하는 모양(withSignatureBoundary)이 채우고, 다음 입력에서 편집기도 채운다', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
editor.view.dispatch(editor.state.tr.insertText('감').setMeta('composition', 1))
@@ -254,6 +269,78 @@ describe('서명 자리 지키기 — 하나뿐 · 앞에는 늘 빈 줄 (2026-1
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '감 ', '', SIGNED])
})
describe('한글 조합이 끝나면 (2026-11-10 실사용 시험 — 띄어쓰기를 쳐야 빈 줄이 돌아왔다)', () => {
it('다른 입력 없이도 빈 줄을 다시 둔다 — ProseMirror 가 조합을 마친 다음 차례에, 커서는 쓰던 자리 · 실행 취소는 조합한 글과 함께', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
compose(editor, '한', 1)
expect(editor.view.composing).toBe(true)
endComposition(editor, '한')
// 조합을 마친 그 순간에는 아직 — ProseMirror 가 조합을 마무리한 다음 차례에 넣는다
expect(editor.view.composing).toBe(false)
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '한', SIGNED])
vi.runOnlyPendingTimers()
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '한', '', SIGNED])
expect(editor.state.selection.$from.parent.textContent).toBe('한')
expect(editor.state.selection.$from.parentOffset).toBe(1)
expect(blocks(reopened(editor)).slice(0, 4)).toEqual(['안녕하세요.', '한', '', SIGNED])
editor.commands.undo()
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '', SIGNED])
})
it('다음 조합이 곧바로 이어지면(이어 쓰는 한글) 조합 중에는 건드리지 않고, 그 조합이 끝난 뒤에 넣는다', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
compose(editor, '한', 1)
endComposition(editor, '한')
compose(editor, '국', 2)
vi.runOnlyPendingTimers()
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['안녕하세요.', '한국', SIGNED])
endComposition(editor, '국')
vi.runOnlyPendingTimers()
expect(blocks(editor.state.doc).slice(0, 4)).toEqual(['안녕하세요.', '한국', '', SIGNED])
expect(editor.state.selection.$from.parentOffset).toBe(2)
})
it('빈 줄이 빠지지 않았으면 아무것도 하지 않는다 — 쓸 줄에서 조합해도 거래를 보내지 않는다', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 0)
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
compose(editor, '네', 1)
endComposition(editor, '네')
const before = editor.state
vi.runOnlyPendingTimers()
expect(editor.state).toBe(before)
expect(blocks(editor.state.doc).slice(0, 3)).toEqual(['네안녕하세요.', '', SIGNED])
})
it('편집기를 내리면 조합이 끝나도 아무것도 하지 않는다 — 기다리던 차례와 듣던 것을 거둔다', () => {
const editor = mountedEditor(WRITTEN)
cursorInto(editor, 1)
vi.useFakeTimers({ toFake: ['setTimeout', 'clearTimeout'] })
const dom = editor.view.dom
compose(editor, '한', 1)
endComposition(editor, '한')
editor.destroy()
expect(() => vi.runOnlyPendingTimers()).not.toThrow()
const pending = vi.getTimerCount()
dom.dispatchEvent(new CompositionEvent('compositionend', { bubbles: true, data: '한' }))
expect(vi.getTimerCount()).toBe(pending)
})
})
it('빈 줄이 있으면 그대로 — 앱이 만든 저장본 모양도 바꾸지 않는다', () => {
const app = doc(
`<div style="${WRAPPER_STYLE}">` +
@@ -5,14 +5,16 @@
// 저장본 · 보내기 취소로 되돌린 메일은 "맨 위 단계에서 빈 문단 바로 뒤의 속성 없는 div 이고 글이나 그림이 있다"(서버 정화가 data-* 를 지운다).
// 맨 위 단계 = 문서 바로 아래 · 또는 문서에 하나뿐인 감싸는 div(보낸 본문의 글꼴 div) 바로 아래. 원문 인용 안의 div 는 서명 자리가 아니다.
// - 서명 자리는 하나다 — 붙여 넣기 · 끌어 놓기로 하나 더 생기면 처음부터 있던 것만 두고 나머지는 문단으로 푼다(appendTransaction).
// - 서명 자리 바로 앞에는 늘 빈 줄 — 그 빈 줄에 글을 쓰거나 Backspace 로 지우면 빈 줄을 다시 넣는다(appendTransaction — 한글 조합 중에는 미룬다),
// 보내고 저장하는 모양도 한 번 더 지킨다(withSignatureBoundary — MailEditor 의 contentOf). 빈 줄이 빠지면 다시 열 때 경계를 찾지 못한다(2026-11-10 리뷰 M3).
// - 서명 자리 바로 앞에는 늘 빈 줄 — 그 빈 줄에 글을 쓰거나 Backspace 로 지우면 빈 줄을 다시 넣는다(appendTransaction — 한글 조합 중에는 미루고
// 조합이 끝나면 다른 입력 없이 채운다), 보내고 저장하는 모양도 한 번 더 지킨다(withSignatureBoundary — MailEditor 의 contentOf).
// 빈 줄이 빠지면 다시 열 때 경계를 찾지 못한다(2026-11-10 리뷰 M3).
// - 바꾸기(replaceSignatureTransaction): 그 자리만 새 서명으로 — 쓴 글 · 인용 · 첨부는 그대로이고 실행 취소로 되돌린다.
// 넣지 않음은 서명 자리와 그 앞의 빈 줄을 뺀다. 서명 자리가 없으면 처음 넣는 자리(새 메일은 본문 끝, 답장 · 전달은 원문 인용 위)에 빈 줄 하나 뒤로 넣는다.
// - 어느 서명이 들었는지(signatureFingerprintOf)는 글(공백을 하나로 줄여)과 그림 주소로 견준다 — 고쳤으면 어느 서명과도 같지 않다.
import { Node, createDocument } from '@tiptap/core'
import { Fragment, type Node as ProseMirrorNode, type Schema } from '@tiptap/pm/model'
import { Plugin, PluginKey, type EditorState, type Transaction } from '@tiptap/pm/state'
import { Plugin, PluginKey, type EditorState, type PluginView, type Transaction } from '@tiptap/pm/state'
import type { EditorView } from '@tiptap/pm/view'
import { blockLines, normalizeIncomingHtml } from '@/components/mail/editor/mail-serialize'
import { SIGNATURE_IMAGE_NODE } from '@/components/mail/editor/signature-image'
@@ -191,6 +193,15 @@ export function withSignatureBoundary(doc: ProseMirrorNode): ProseMirrorNode {
const signatureBlockKey = new PluginKey('mailSignatureBlock')
/** 조합이 끝났다 — 문서를 바꾸지 않는 거래에 이 표시를 달아 서명 자리 지키기(appendTransaction)를 깨운다 */
const COMPOSITION_ENDED = 'compositionEnded'
/**
* 한글 조합이 끝난 뒤 미뤄 둔 빈 줄을 채우는 때 — ProseMirror 는 compositionend 뒤 20ms 에 조합을 마무리한다(endComposition).
* 그 다음 차례에 본다 (눈으로는 조합을 마친 그때와 같다)
*/
const COMPOSITION_SETTLE_DELAY_MS = 50
/**
* 서명 자리는 하나 — 붙여 넣기(서명 안에서 복사한 글은 서명 자리째 붙는다) 등으로 둘 이상이 되면 처음부터 있던 것만 두고 나머지는 문단으로 푼다.
* 처음부터 있던 것을 모르면(없었거나 지워졌다) 첫 것을 둔다
@@ -230,10 +241,12 @@ function unwrapExtraSignatures(
* 서명 자리 지키기 — 문서가 바뀐 거래 뒤에 (1) 서명 자리를 하나로 (2) 서명 자리 바로 앞에 빈 줄을 둔다.
* (2)는 그 빈 줄에 글을 쓰거나(Backspace · Delete 로) 빈 줄을 지웠을 때 빈 줄을 다시 넣는 것이다 — 쓴 글은 그대로 두고 그 아래 · 서명 위에 새 빈 줄,
* 커서는 쓰던 자리 그대로, 실행 취소는 그 입력과 한 번에 되돌린다(덧붙인 거래는 입력과 같은 기록 묶음).
* 한글을 조합하는 중(거래의 composition 표시)에는 미룬다 — 조합 중인 글 옆의 화면을 바꾸지 않게. 다음 거래 · 보내고 저장하는 모양(withSignatureBoundary)이 채운다
* 한글을 조합하는 중(거래의 composition 표시)에는 미룬다 — 조합 중인 글 옆의 화면을 바꾸지 않게. 조합이 끝나면 compositionSettleView 가
* 표시(COMPOSITION_ENDED)만 단 거래로 깨워 채우고, 그 전에 보내거나 저장하는 모양은 withSignatureBoundary 가 채운다
*/
function guardSignatureBlock(transactions: readonly Transaction[], oldState: EditorState, newState: EditorState) {
if (!transactions.some((transaction) => transaction.docChanged)) return null
const woken = transactions.some((transaction) => transaction.getMeta(signatureBlockKey) === COMPOSITION_ENDED)
if (!woken && !transactions.some((transaction) => transaction.docChanged)) return null
const tr = newState.tr
unwrapExtraSignatures(transactions, oldState, newState, tr)
const composing = transactions.some((transaction) => Boolean(transaction.getMeta('composition')))
@@ -243,6 +256,41 @@ function guardSignatureBlock(transactions: readonly Transaction[], oldState: Edi
return tr.docChanged ? tr : null
}
/**
* 한글 조합이 끝나면 미뤄 둔 빈 줄을 채운다 — 조합을 마칠 때는 대개 거래가 오지 않고(조합한 글은 조합 중에 이미 들어갔다)
* 오더라도 조합 표시를 달고 온다. 그래서 다른 입력 · 화살표 · 다른 칸 누르기만으로는 빈 줄이 돌아오지 않았다(2026-11-10 실사용 시험).
* compositionend 뒤 ProseMirror 가 조합을 마무리한 다음 차례에 빈 줄이 빠져 있으면 표시만 단 빈 거래로 지키기를 깨운다 —
* 빈 줄은 덧붙인 거래라 커서는 쓰던 자리, 실행 취소는 조합한 글과 한 번에 되돌린다.
* 그사이 새 조합이 시작됐으면(이어 쓰는 한글) 건드리지 않는다 — 그 조합이 끝날 때 다시 본다
*/
function compositionSettleView(view: EditorView): PluginView {
let timer: ReturnType<typeof setTimeout> | null = null
function cancel() {
if (timer !== null) clearTimeout(timer)
timer = null
}
function settle() {
timer = null
if (view.isDestroyed || view.composing || !blockMissingBlankLine(view.state.doc)) return
view.dispatch(view.state.tr.setMeta(signatureBlockKey, COMPOSITION_ENDED))
}
function onCompositionEnd() {
cancel()
timer = setTimeout(settle, COMPOSITION_SETTLE_DELAY_MS)
}
view.dom.addEventListener('compositionend', onCompositionEnd)
return {
destroy() {
cancel()
view.dom.removeEventListener('compositionend', onCompositionEnd)
},
}
}
/** 서명 자리 노드 — 쓰기 본문 스키마(mail-extensions.ts)에 든다. 서명 칸(SignatureEditor) 스키마에는 없다 */
export const MailSignatureBlock = Node.create({
name: MAIL_SIGNATURE_NODE,
@@ -264,6 +312,6 @@ export const MailSignatureBlock = Node.create({
},
addProseMirrorPlugins() {
return [new Plugin({ key: signatureBlockKey, appendTransaction: guardSignatureBlock })]
return [new Plugin({ key: signatureBlockKey, appendTransaction: guardSignatureBlock, view: compositionSettleView })]
},
})