generated from ttipo/project-base
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
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:
File diff suppressed because one or more lines are too long
@@ -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 })]
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user