generated from ttipo/project-base
chore(docker): 메일 엔진·네트워크 분리·같은 주소 배포와 Node 24 고정 #1
Generated
+583
@@ -8,6 +8,13 @@
|
||||
"name": "frontend",
|
||||
"version": "0.0.0",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/extension-table": "3.31.4",
|
||||
"@tiptap/extension-text-align": "3.31.4",
|
||||
"@tiptap/extension-text-style": "3.31.4",
|
||||
"@tiptap/extensions": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4",
|
||||
"@tiptap/starter-kit": "3.31.4",
|
||||
"axios": "^1.14.0",
|
||||
"dompurify": "^3.4.17",
|
||||
"pinia": "^3.0.4",
|
||||
@@ -1710,6 +1717,419 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@tiptap/core": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.31.4.tgz",
|
||||
"integrity": "sha512-cIVUQYBycighzsk6GbmCef8ZiEDVxoT5EuAe4xg2q9dNkDqCXVqI4YGr9aavCQEiAS4uHiRwqvcsPqJHipFDfg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-blockquote": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.31.4.tgz",
|
||||
"integrity": "sha512-FHnbvCIW0eDKgUUFjmvEB0ax9YUBW7/Unm+38qpuQROvU872yRqhnDtycKT9IKk46jNdjM4A4fAGUbge10CrAA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bold": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.31.4.tgz",
|
||||
"integrity": "sha512-QX1xoXvO0gz6uVsoLegumqQ4MJXL7lcUDQZ4J7la7LBqiIh4efrCmp2EXbhpr37M/YQL7w8q3g7H/VovSh7xIg==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-bullet-list": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.31.4.tgz",
|
||||
"integrity": "sha512-gHn1RDR4olCnmKpH+G9x+f8R+iKPNVdLlvbFkIpR/stjabZi7fB4Rr5GiHWsulbtxAjdQrNjnVeNdGM4rBxApw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.31.4.tgz",
|
||||
"integrity": "sha512-pzi8GNDs5J1DEoO1HNYiJ5WZpgOZzlc/2VIa77mMpEuD4hZxuK0BQzlF/O6Ps/O5P1lGJT4xS0Yowpzb9mD9RA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-code-block": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.31.4.tgz",
|
||||
"integrity": "sha512-su/B/cfQ1HpiHl6JfJ6wa+RVEptiYsV4dCntE8XQNj2dUcII4y6w6PQbbswOoDrU79uXHsAvpy44215rghG4LA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-document": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.31.4.tgz",
|
||||
"integrity": "sha512-vT20u62RWR+BLIyNJ54e24MPUY4zm8VF7tcgJnxQXi7BgVJWcNMtMWWDIkPJ4JNXwfoIznKh3jzYsaYmbCJgQQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-dropcursor": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.31.4.tgz",
|
||||
"integrity": "sha512-5TbCPCC4RL+QT1NYHjXCYDBHBtVqYOFvFEnk3v98lvXCM2PNL13ofXUfJPuykAVy1OdTAx4DlZOQAD9WKy/IdQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-gapcursor": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.31.4.tgz",
|
||||
"integrity": "sha512-hBogUAepQH6IQUhaePhPWzQ1Oeu+yqlJVMKO06Eue5m36uRy1PZZAQ780pCGT0SC4R5MH2td1N8EUQ7bupQdDA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extensions": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-hard-break": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.31.4.tgz",
|
||||
"integrity": "sha512-oLNajLyUvRMg1qtzFgKGduygTlpaHFB9PU+M+pUWdA4sa1bRnxS2Y++Ao4lHYopJ7nxatLndrYoCIHkotqkPDQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-heading": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.31.4.tgz",
|
||||
"integrity": "sha512-KqlSeLathpdxCc3kVHBCA/RkHvb+V7u5vgiSEu1Cv3L70r7F19COMGODav/k1AGYBgEXKL6lM9cy8cloE5/28g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-horizontal-rule": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.31.4.tgz",
|
||||
"integrity": "sha512-ws0Kh8kfHL7ESILSXj0HJAXs8G10ewlW1sedXKm8dm0op9L8il+xl8bEJVoqG/jMf77900Fc2CaQldcCGRpF1g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-italic": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.31.4.tgz",
|
||||
"integrity": "sha512-pftTQBPYZocNDAj/u6OyDIp7YxmN0+N895cQPogJQ6qMHdLiRhzgawiEewJkniyN1w8k3Wx/Etp8pTKrxaOOBQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-link": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.31.4.tgz",
|
||||
"integrity": "sha512-LLxW3qfeL9gvDdMPVdxYFYZ0zZSjsoIDhOqjJ5U2UfaK3U2iwkKTBgV2qT+FWf3uUHmBRjBZ08q3dRPmWBAD5g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"linkifyjs": "^4.3.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.31.4.tgz",
|
||||
"integrity": "sha512-Wd4YRYxOdf+TtF33/QsRo1RK1CecVxjYGjUBIKNGHAywfh8tghgV4YOUCiV1iJqYt2IBs+RWlIqTmdIPzBrd4A==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-item": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.31.4.tgz",
|
||||
"integrity": "sha512-ao8XAPvhOrtGFILs+fKtOM91omDSpUC8X19m44gbaq5xJDVpIMZ0rP+NAVUS72G7LskhcHa0bu/Pm022bUVjVw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-list-keymap": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.31.4.tgz",
|
||||
"integrity": "sha512-etdkl/YnYfwtALDxecJ+4WYgJAez1fn1vzzZqJklZBpgqiZtny3NxpvWS7/JhsltNG84Wo/oovpfJgVmzhddrw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-ordered-list": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.31.4.tgz",
|
||||
"integrity": "sha512-UNWyF+Dhm08znszIMnT8Y2kJ1lvtiCvUGs1aG7m9cPHxDa7VycWnOqdtXWkc+6xRLNiC0zgWCFWgLLHduV8pRQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/extension-list": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-paragraph": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.31.4.tgz",
|
||||
"integrity": "sha512-WZlBgw+XVKXerRlmEsoQ7x1PwSU+dh3X+b70+ViZsLebdfsmmYBIaNDrvIGwG9UQr5eLnddJnZplMSTI2YKs+g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-strike": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.31.4.tgz",
|
||||
"integrity": "sha512-4MBr63TJIMqxCjE+gAPdlY1IdELgvbAP516K3Ntn/BkcpEbEoPMEYVz+XTxULyHQEuARDO6jsXPPxRXpA74jaw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-table": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-table/-/extension-table-3.31.4.tgz",
|
||||
"integrity": "sha512-ai7DLwg/xmhlUas3LgWaPQdPlZ8StliY6Dn+iUH6eyZ/TQqRDY/oeIsKWaHpXq22T5M5dMlZPTg172ZdtwKswQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.31.4.tgz",
|
||||
"integrity": "sha512-WL2ZRpFksgQcXJN05/UQH09YCH9LlhdLrSEcviASvEH/aAfsKiJlDrouN8PET+YAWK/F5xFc5/Q1z0xj+I23jw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-align": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-align/-/extension-text-align-3.31.4.tgz",
|
||||
"integrity": "sha512-Z8x/x2N4BiFwu7+UfJABT/OQbwZPUHbmvAH6Fo5lefgeIlTwAXFBzz4x7Eeed7iJAcXUzOnyd6bmkhK36gYJ2g==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-text-style": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-text-style/-/extension-text-style-3.31.4.tgz",
|
||||
"integrity": "sha512-djeemliEWmqs3cJrUyMG+17HbjHyf6D/0NkIHl/RkVHYOCX1AYMZ7r+XLefApSNBCAx/aFh1hJzaMlnGbnTaAw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extension-underline": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.31.4.tgz",
|
||||
"integrity": "sha512-0iFGPIqmqGkCrOFh1OPo7Dzv5s86dVN3bZaugsz0jK04otkb8bn04ZDKP2/6Wq+lcK0M9DfJ0QqfW5hC2nBZxQ==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/extensions": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.31.4.tgz",
|
||||
"integrity": "sha512-nIupIne/sHpV2Kw1M4MOmr4+G454KI8BXM5+LWKOU2EpzfALZWVSy7koF9lVRna4Zp5Gv8hfg5Z8kvmrYQvhkw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/pm": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.31.4.tgz",
|
||||
"integrity": "sha512-x7dwth79cQp4QGae66/RJqMDJQv0+n7b7dD7cyFQBWqzPFLJxN/4paHY4qbWedZjNLhRyIOAD9a5q4AYM1zmPA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"prosemirror-changeset": "^2.4.1",
|
||||
"prosemirror-commands": "^1.7.1",
|
||||
"prosemirror-dropcursor": "^1.8.2",
|
||||
"prosemirror-gapcursor": "^1.4.1",
|
||||
"prosemirror-history": "^1.5.0",
|
||||
"prosemirror-inputrules": "^1.5.1",
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.11",
|
||||
"prosemirror-schema-list": "^1.5.1",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-tables": "^1.8.5",
|
||||
"prosemirror-transform": "^1.12.0",
|
||||
"prosemirror-view": "^1.42.3"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tiptap/starter-kit": {
|
||||
"version": "3.31.4",
|
||||
"resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.31.4.tgz",
|
||||
"integrity": "sha512-u7HIJ9vK6kFe4S4J2CIcDLrhRZzoTMSyU0XxjQ6lUNF7pWW3rMi//pvi8RcsZmxGjyb8CpXAqYgNl7f9WyaUeA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/extension-blockquote": "3.31.4",
|
||||
"@tiptap/extension-bold": "3.31.4",
|
||||
"@tiptap/extension-bullet-list": "3.31.4",
|
||||
"@tiptap/extension-code": "3.31.4",
|
||||
"@tiptap/extension-code-block": "3.31.4",
|
||||
"@tiptap/extension-document": "3.31.4",
|
||||
"@tiptap/extension-dropcursor": "3.31.4",
|
||||
"@tiptap/extension-gapcursor": "3.31.4",
|
||||
"@tiptap/extension-hard-break": "3.31.4",
|
||||
"@tiptap/extension-heading": "3.31.4",
|
||||
"@tiptap/extension-horizontal-rule": "3.31.4",
|
||||
"@tiptap/extension-italic": "3.31.4",
|
||||
"@tiptap/extension-link": "3.31.4",
|
||||
"@tiptap/extension-list": "3.31.4",
|
||||
"@tiptap/extension-list-item": "3.31.4",
|
||||
"@tiptap/extension-list-keymap": "3.31.4",
|
||||
"@tiptap/extension-ordered-list": "3.31.4",
|
||||
"@tiptap/extension-paragraph": "3.31.4",
|
||||
"@tiptap/extension-strike": "3.31.4",
|
||||
"@tiptap/extension-text": "3.31.4",
|
||||
"@tiptap/extension-underline": "3.31.4",
|
||||
"@tiptap/extensions": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4"
|
||||
},
|
||||
"funding": {
|
||||
"type": "github",
|
||||
"url": "https://github.com/sponsors/ueberdosis"
|
||||
}
|
||||
},
|
||||
"node_modules/@tsconfig/node24": {
|
||||
"version": "24.0.4",
|
||||
"resolved": "https://registry.npmjs.org/@tsconfig/node24/-/node24-24.0.4.tgz",
|
||||
@@ -4785,6 +5205,12 @@
|
||||
"url": "https://opencollective.com/parcel"
|
||||
}
|
||||
},
|
||||
"node_modules/linkifyjs": {
|
||||
"version": "4.3.3",
|
||||
"resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.3.tgz",
|
||||
"integrity": "sha512-P8aEP5U/D1/IlTY2OeYsErdwh9bGuLE30NcXtKEjgdHcahveQoQwM2yZNsioQHsWFz0P7KKudisbrzCgR0sDHg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/locate-path": {
|
||||
"version": "6.0.0",
|
||||
"resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz",
|
||||
@@ -5167,6 +5593,12 @@
|
||||
"node": ">= 0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/orderedmap": {
|
||||
"version": "2.1.1",
|
||||
"resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
|
||||
"integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/oxfmt": {
|
||||
"version": "0.72.0",
|
||||
"resolved": "https://registry.npmjs.org/oxfmt/-/oxfmt-0.72.0.tgz",
|
||||
@@ -5472,6 +5904,145 @@
|
||||
"integrity": "sha512-PaQAADyLY5v4kYFwkpSJHbSSYIkiriY/1xXw75TKoZ9UQQqeU+tvP05yTdZAWibiIYoo8ZKtRv8PM7w0IaywSw==",
|
||||
"license": "OFL-1.1"
|
||||
},
|
||||
"node_modules/prosemirror-changeset": {
|
||||
"version": "2.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.4.tgz",
|
||||
"integrity": "sha512-Gyuz9jYdrAeA9I7AholyOes9KUmv/lEwsC5O9s5xF5VB3EgTPsXMGUxz4+H1B+wKVPdZbvRcg1Cp3x8qX+8ofA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-commands": {
|
||||
"version": "1.7.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.2.tgz",
|
||||
"integrity": "sha512-q6Q6szxqdu9Xd6EcdKsqXghu5nQdZTpB4Q9yd04WRc7/jt763e/rT60Owh0L1GYY+T46o5rD+9lEN36dZS43tw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.10.2"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-dropcursor": {
|
||||
"version": "1.8.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.4.tgz",
|
||||
"integrity": "sha512-VmKxhcA6a+h5oPSYmfF1ZNeQqPbyYaIxqZsKqeYCyTlPtrjUgNaHL3P8vVaO19oqAV0zuaM/axkowZHrB1P3Tg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0",
|
||||
"prosemirror-view": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-gapcursor": {
|
||||
"version": "1.4.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
|
||||
"integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.0.0",
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-view": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-history": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.1.tgz",
|
||||
"integrity": "sha512-onlwnqKvgtFg6srZOmDOAhlAy1FIHQpOExORybX0S+DvJepE5tPALrB9iGet/2sqqqrelSnB10OoiOJqAvu2DA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.2.2",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.31.0",
|
||||
"rope-sequence": "^1.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-inputrules": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-inputrules/-/prosemirror-inputrules-1.5.1.tgz",
|
||||
"integrity": "sha512-7wj4uMjKaXWAQ1CDgxNzNtR9AlsuwzHfdFH1ygEHA2KHF2DOEaXl1CJfNPAKCg9qNEh4rum975QLaCiQPyY6Fw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-keymap": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
|
||||
"integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"w3c-keyname": "^2.2.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-model": {
|
||||
"version": "1.25.12",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.12.tgz",
|
||||
"integrity": "sha512-Ue2gTmXMa7EhpLNhC7J+h4+ykD8ha12K6rrZFFKKJHBForfIStw5gJ6Zrf1mqqAa9NmpmEB4wp9bKmA4eUVjWg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"orderedmap": "^2.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-schema-list": {
|
||||
"version": "1.5.1",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
|
||||
"integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.7.3"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-state": {
|
||||
"version": "1.4.4",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
|
||||
"integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.0.0",
|
||||
"prosemirror-transform": "^1.0.0",
|
||||
"prosemirror-view": "^1.27.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-tables": {
|
||||
"version": "1.8.5",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
|
||||
"integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-keymap": "^1.2.3",
|
||||
"prosemirror-model": "^1.25.4",
|
||||
"prosemirror-state": "^1.4.4",
|
||||
"prosemirror-transform": "^1.10.5",
|
||||
"prosemirror-view": "^1.41.4"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-transform": {
|
||||
"version": "1.12.2",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.2.tgz",
|
||||
"integrity": "sha512-PE/aY0HEY4zczvmqrilgkUK/WautF0chvMqkmM/iN5/aPHvwrCWaQZjS6CIZp+K84YrvPFqrL+YArrfJX8xX7g==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.21.0"
|
||||
}
|
||||
},
|
||||
"node_modules/prosemirror-view": {
|
||||
"version": "1.42.6",
|
||||
"resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.42.6.tgz",
|
||||
"integrity": "sha512-3g25f6hKV2bBxo3puH3EcYWmmVNSH0iWmNsMVT7sugDSpA025jgpOLarcDWr39SdzPjYMaiYtRdNtXialPSjEQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"prosemirror-model": "^1.25.12",
|
||||
"prosemirror-state": "^1.0.0",
|
||||
"prosemirror-transform": "^1.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/proto-list": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz",
|
||||
@@ -5611,6 +6182,12 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/rope-sequence": {
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
|
||||
"integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/run-applescript": {
|
||||
"version": "7.1.0",
|
||||
"resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz",
|
||||
@@ -6754,6 +7331,12 @@
|
||||
"typescript": ">=5.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/w3c-keyname": {
|
||||
"version": "2.2.8",
|
||||
"resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
|
||||
"integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/w3c-xmlserializer": {
|
||||
"version": "5.0.0",
|
||||
"resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz",
|
||||
|
||||
@@ -15,6 +15,13 @@
|
||||
"format": "oxfmt src/"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tiptap/core": "3.31.4",
|
||||
"@tiptap/extension-table": "3.31.4",
|
||||
"@tiptap/extension-text-align": "3.31.4",
|
||||
"@tiptap/extension-text-style": "3.31.4",
|
||||
"@tiptap/extensions": "3.31.4",
|
||||
"@tiptap/pm": "3.31.4",
|
||||
"@tiptap/starter-kit": "3.31.4",
|
||||
"axios": "^1.14.0",
|
||||
"dompurify": "^3.4.17",
|
||||
"pinia": "^3.0.4",
|
||||
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -71,10 +71,13 @@ describe('useAttachmentUploads', () => {
|
||||
const serverLimit = 5 * BYTES_PER_MB
|
||||
const { uploads, stop } = withUploads({ maxBytes: () => serverLimit })
|
||||
|
||||
uploads.addFiles([file('작은.pdf', serverLimit), file('큰.pdf', serverLimit + 1)])
|
||||
const small = file('작은.pdf', serverLimit)
|
||||
const accepted = uploads.addFiles([small, file('큰.pdf', serverLimit + 1)])
|
||||
|
||||
expect(uploads.items.value.map((item) => item.status)).toEqual(['uploading', 'failed'])
|
||||
expect(uploads.items.value[1]?.errorMessage).toBe(ERROR_CODE_MESSAGES.FILE_002)
|
||||
// 올리기 시작한 파일만 돌려준다 — 화면은 이것으로 "이미지는 첨부로" 알림을 띄울지 정한다
|
||||
expect(accepted).toEqual([small])
|
||||
stop()
|
||||
})
|
||||
|
||||
|
||||
@@ -129,8 +129,10 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
/**
|
||||
* 새 파일을 올린다 — 사전 검사(개수·형식·파일당 한도·합계 한도)에 걸리면 올리지 않고 실패로 표시한다.
|
||||
* 여러 개를 한 번에 고르면 앞에서부터 붙이며 개수·합계를 센다 (넘는 파일만 빠진다).
|
||||
* @returns 사전 검사를 통과해 올리기 시작한 파일 — 화면이 알림을 띄울지 정하는 데 쓴다
|
||||
*/
|
||||
function addFiles(files: Iterable<File>) {
|
||||
function addFiles(files: Iterable<File>): File[] {
|
||||
const accepted: File[] = []
|
||||
for (const file of files) {
|
||||
const key = takeKey()
|
||||
const rejected = precheckAttachment(file, limitsFor('upload'), attached.value)
|
||||
@@ -148,8 +150,12 @@ export function useAttachmentUploads(options: AttachmentUploadOptions = {}) {
|
||||
missing: false,
|
||||
},
|
||||
]
|
||||
if (!rejected) void upload(file, key)
|
||||
if (!rejected) {
|
||||
accepted.push(file)
|
||||
void upload(file, key)
|
||||
}
|
||||
}
|
||||
return accepted
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
|
||||
import { isFileDrag } from '@/shared/utils/editor-files'
|
||||
|
||||
/**
|
||||
* 화면 어디에 놓아도 파일을 받는다 — 이 화면이 떠 있는 동안 window 에서 파일 끌기를 받는다
|
||||
* (디자인 README "본문 이미지 → 첨부 파일": 쓰기 화면 어디에 놓아도 첨부로 올린다).
|
||||
* 받지 않은 곳에 놓으면 브라우저가 그 파일을 열어(페이지 이동) 쓰던 내용을 잃는다.
|
||||
* - 파일 끌기의 dragover · drop 기본 동작을 막는다 — 글을 끄는 것은 그대로 둔다
|
||||
* - 다른 곳(본문 편집기)이 이미 처리한 놓기(defaultPrevented)는 다시 넘기지 않는다
|
||||
* - dragging: 파일을 끌어 창 위에 있는 동안 true — 들어온 요소 수를 세어 자식 사이를 지날 때 깜빡이지 않고,
|
||||
* 들어온 만큼 나가면(창 밖으로 · Esc 로 그만둠 — 브라우저가 dragleave 를 보낸다) false
|
||||
*/
|
||||
export function useWindowFileDrop(onFiles: (files: File[]) => void) {
|
||||
const depth = ref<number>(0)
|
||||
const dragging = computed(() => depth.value > 0)
|
||||
|
||||
function onDragEnter(event: DragEvent) {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
depth.value += 1
|
||||
}
|
||||
|
||||
function onDragOver(event: DragEvent) {
|
||||
if (!isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
if (event.dataTransfer) event.dataTransfer.dropEffect = 'copy'
|
||||
}
|
||||
|
||||
function onDragLeave(event: DragEvent) {
|
||||
if (!isFileDrag(event)) return
|
||||
depth.value = Math.max(0, depth.value - 1)
|
||||
}
|
||||
|
||||
function onDrop(event: DragEvent) {
|
||||
depth.value = 0
|
||||
if (event.defaultPrevented || !isFileDrag(event)) return
|
||||
event.preventDefault()
|
||||
onFiles(Array.from(event.dataTransfer?.files ?? []))
|
||||
}
|
||||
|
||||
const listeners = [
|
||||
['dragenter', onDragEnter],
|
||||
['dragover', onDragOver],
|
||||
['dragleave', onDragLeave],
|
||||
['drop', onDrop],
|
||||
] as const
|
||||
|
||||
onMounted(() => {
|
||||
for (const [type, listener] of listeners) window.addEventListener(type, listener)
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
for (const [type, listener] of listeners) window.removeEventListener(type, listener)
|
||||
})
|
||||
|
||||
return { dragging }
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useToastStore } from '@/stores/toast.store'
|
||||
import { flushPromises, installMockApi, ok, restoreApiAdapter, type MockHandler } from '@/test-utils/mock-api'
|
||||
import { installProseMirrorDom } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
/**
|
||||
* 편집기 청크 내려받기를 마음대로 — 실패시키거나(첫 번째만) 사용자가 놓아 줄 때까지 붙잡는다.
|
||||
* ComposePage 는 mail-editor-handle.ts 의 loadMailEditor 로 청크를 받는다.
|
||||
*/
|
||||
const loader = vi.hoisted(() => ({
|
||||
mode: 'normal' as 'normal' | 'fail-once' | 'hold',
|
||||
calls: 0,
|
||||
release: null as null | (() => void),
|
||||
}))
|
||||
|
||||
vi.mock('@/components/mail/editor/mail-editor-handle', async (importOriginal) => {
|
||||
const actual = await importOriginal<typeof import('@/components/mail/editor/mail-editor-handle')>()
|
||||
return {
|
||||
...actual,
|
||||
loadMailEditor: () => {
|
||||
loader.calls += 1
|
||||
if (loader.mode === 'fail-once' && loader.calls === 1) {
|
||||
return Promise.reject(new Error('Failed to fetch dynamically imported module'))
|
||||
}
|
||||
if (loader.mode === 'hold') {
|
||||
return new Promise((resolve) => {
|
||||
loader.release = () => resolve(actual.loadMailEditor())
|
||||
})
|
||||
}
|
||||
return actual.loadMailEditor()
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 10_000
|
||||
|
||||
const ORIGINAL: MessageDetail = {
|
||||
id: 'm1',
|
||||
threadId: 't1',
|
||||
mailboxIds: ['inbox'],
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: USER.email }],
|
||||
subject: '메일 서버 이전 일정 공유',
|
||||
preview: '',
|
||||
receivedAt: '2026-10-26T01:42:00Z',
|
||||
isRead: true,
|
||||
isFlagged: false,
|
||||
hasAttachment: false,
|
||||
size: 100,
|
||||
cc: [],
|
||||
bcc: [],
|
||||
replyTo: [],
|
||||
sentAt: null,
|
||||
messageId: '<m1@arasystem.it.kr>',
|
||||
references: [],
|
||||
htmlBody: null,
|
||||
textBody: '김아라님, 안녕하세요.',
|
||||
hasBlockedImages: false,
|
||||
attachments: [],
|
||||
}
|
||||
|
||||
const api: MockHandler = ({ method, url }) => {
|
||||
if (method === 'GET' && url === '/messages/m1') return ok(ORIGINAL)
|
||||
return ok({ items: [], pageInfo: { hasNext: false, nextCursor: null, total: 0 } })
|
||||
}
|
||||
|
||||
describe('ComposePage — 서식 편집기 청크', () => {
|
||||
let pinia: Pinia
|
||||
let wrapper: VueWrapper | null = null
|
||||
const loadErrors: unknown[] = []
|
||||
|
||||
beforeAll(installProseMirrorDom)
|
||||
beforeAll(async () => {
|
||||
await import('@/components/mail/editor/MailEditor.vue')
|
||||
}, EDITOR_LOAD_TIMEOUT_MS)
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
useAuthStore().markAuthenticated(USER)
|
||||
loader.mode = 'normal'
|
||||
loader.calls = 0
|
||||
loader.release = null
|
||||
loadErrors.length = 0
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
wrapper = null
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
})
|
||||
|
||||
async function mountCompose(path: string): Promise<VueWrapper> {
|
||||
installMockApi(api)
|
||||
const router = createTestRouter()
|
||||
await router.push(path)
|
||||
await router.isReady()
|
||||
wrapper = mount(ComposePage, {
|
||||
attachTo: document.body,
|
||||
global: {
|
||||
plugins: [pinia, router],
|
||||
// 내려받기 오류는 Vue 가 앱 오류로도 알린다 — 테스트에서는 모아 둔다
|
||||
config: { errorHandler: (error) => loadErrors.push(error) },
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
/** 붙잡아 둔 청크를 놓아 준다 — 편집기 자리가 그려져 내려받기가 시작될 때까지 기다린 뒤 */
|
||||
async function releaseEditor() {
|
||||
await vi.waitFor(() => {
|
||||
if (!loader.release) throw new Error('아직 내려받기를 시작하지 않았다')
|
||||
})
|
||||
loader.release?.()
|
||||
}
|
||||
|
||||
async function editorLoaded(view: VueWrapper) {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!view.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
it('청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도" — 보내기는 막힌다. 다시 시도하면 편집기가 뜬다', async () => {
|
||||
loader.mode = 'fail-once'
|
||||
const view = await mountCompose('/mail/compose')
|
||||
|
||||
await vi.waitFor(() => {
|
||||
if (!view.find('.compose__editor [role="alert"]').exists()) throw new Error('아직 실패를 보이지 않는다')
|
||||
})
|
||||
const alert = view.get('.compose__editor [role="alert"]')
|
||||
expect(alert.text()).toContain(ERROR_CODE_MESSAGES.SYS_001)
|
||||
expect(view.get('.compose__send').attributes('disabled')).toBeDefined()
|
||||
// 편집기에 넘기던 속성(초안 HTML 등)이 오류 화면에 붙지 않는다
|
||||
expect(alert.attributes('initial-html')).toBeUndefined()
|
||||
expect(loadErrors).toHaveLength(1)
|
||||
|
||||
const retry = alert.findAll('button').find((button) => button.text() === '다시 시도')
|
||||
// 누르면 단추에 초점이 간다 (브라우저처럼)
|
||||
;(retry?.element as HTMLElement | undefined)?.focus()
|
||||
await retry?.trigger('click')
|
||||
await editorLoaded(view)
|
||||
|
||||
expect(loader.calls).toBe(2)
|
||||
expect(view.find('.compose__editor [role="alert"]').exists()).toBe(false)
|
||||
expect(view.get('.compose__send').attributes('disabled')).toBeUndefined()
|
||||
// 누른 "다시 시도" 단추가 사라졌다 — 초점은 본문으로
|
||||
await vi.waitFor(() => {
|
||||
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
|
||||
})
|
||||
})
|
||||
|
||||
it('답장 — 청크가 늦게 와도 이미 다른 칸(제목)에 쓰고 있으면 본문으로 초점을 빼앗지 않는다', async () => {
|
||||
loader.mode = 'hold'
|
||||
const view = await mountCompose('/mail/compose?replyTo=m1')
|
||||
const subject = view.get('#compose-subject').element as HTMLInputElement
|
||||
subject.focus()
|
||||
|
||||
await releaseEditor()
|
||||
await editorLoaded(view)
|
||||
await flushPromises()
|
||||
|
||||
expect(document.activeElement).toBe(subject)
|
||||
})
|
||||
|
||||
it('답장 — 아무 칸에도 초점이 없으면 편집기가 준비될 때 본문 맨 위로', async () => {
|
||||
loader.mode = 'hold'
|
||||
const view = await mountCompose('/mail/compose?replyTo=m1')
|
||||
;(document.activeElement as HTMLElement | null)?.blur()
|
||||
|
||||
await releaseEditor()
|
||||
await editorLoaded(view)
|
||||
await vi.waitFor(() => {
|
||||
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
|
||||
})
|
||||
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
})
|
||||
})
|
||||
@@ -1,8 +1,14 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { mount, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia, type Pinia } from 'pinia'
|
||||
|
||||
import { ERROR_CODE_MESSAGES } from '@/shared/constants/error-codes.generated'
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAIL_BODY_HTML_MAX_LENGTH } from '@/shared/constants/mail-editor'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
@@ -17,10 +23,13 @@ import {
|
||||
type MockRequest,
|
||||
type MockResult,
|
||||
} from '@/test-utils/mock-api'
|
||||
import { editorOf, fileDragEvent, installProseMirrorDom, pasteEvent } from '@/test-utils/prosemirror-dom'
|
||||
import { createTestRouter } from '@/test-utils/router'
|
||||
import ComposePage from './ComposePage.vue'
|
||||
|
||||
const USER = { email: 'ara.kim@arasystem.it.kr', name: '김아라' }
|
||||
/** 지연 로딩한 편집기를 기다리는 시간 — 테스트를 한꺼번에 돌리면 첫 변환이 느리다 */
|
||||
const EDITOR_LOAD_TIMEOUT_MS = 10_000
|
||||
let clock = Date.UTC(2026, 9, 26, 6, 0, 0)
|
||||
|
||||
const ORIGINAL: MessageDetail = {
|
||||
@@ -51,6 +60,12 @@ const ORIGINAL: MessageDetail = {
|
||||
describe('ComposePage', () => {
|
||||
let pinia: Pinia
|
||||
|
||||
beforeAll(installProseMirrorDom)
|
||||
// 편집기 모듈을 미리 불러 둔다 — 첫 변환 시간(한꺼번에 돌리면 수 초)이 첫 테스트의 시간 한도(기본 5초)에 잡히지 않게
|
||||
beforeAll(async () => {
|
||||
await import('@/components/mail/editor/MailEditor.vue')
|
||||
}, EDITOR_LOAD_TIMEOUT_MS)
|
||||
|
||||
beforeEach(() => {
|
||||
pinia = createPinia()
|
||||
setActivePinia(pinia)
|
||||
@@ -60,13 +75,19 @@ describe('ComposePage', () => {
|
||||
vi.setSystemTime(clock)
|
||||
})
|
||||
|
||||
/** 이 테스트에서 띄운 화면 — 쓰기 화면은 window 에 파일 끌기 처리기를 달므로 다음 테스트로 새지 않게 내린다 */
|
||||
const mountedWrappers: VueWrapper[] = []
|
||||
|
||||
afterEach(() => {
|
||||
for (const wrapper of mountedWrappers.splice(0)) {
|
||||
if (!wrapper.vm.$.isUnmounted) wrapper.unmount()
|
||||
}
|
||||
useToastStore().clear()
|
||||
restoreApiAdapter()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
/** attach: 초점 이동을 확인할 때 문서에 붙인다 (테스트 끝에 wrapper.unmount()) */
|
||||
/** attach: 초점 이동 · 화면 전체의 끌기를 확인할 때 문서에 붙인다 */
|
||||
async function mountCompose(path: string, handler: MockHandler, attach = false) {
|
||||
const requests = installMockApi(handler)
|
||||
const router = createTestRouter()
|
||||
@@ -76,10 +97,28 @@ describe('ComposePage', () => {
|
||||
attachTo: attach ? document.body : undefined,
|
||||
global: { plugins: [pinia, router] },
|
||||
})
|
||||
mountedWrappers.push(wrapper)
|
||||
await flushPromises()
|
||||
await editorLoaded(wrapper)
|
||||
return { wrapper, router, requests }
|
||||
}
|
||||
|
||||
/** 서식 편집기는 지연 로딩한다 — 불러와 준비될 때까지 기다린다 (처음 불러올 때는 변환에 시간이 걸린다) */
|
||||
async function editorLoaded(wrapper: VueWrapper) {
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (!wrapper.find('.ProseMirror').exists()) throw new Error('편집기를 불러오는 중')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
/** 본문 편집 영역에 보이는 글 */
|
||||
function bodyText(wrapper: VueWrapper): string {
|
||||
return wrapper.get('#compose-body').text()
|
||||
}
|
||||
|
||||
function chips(wrapper: VueWrapper, inputId: string): string[] {
|
||||
const field = wrapper.get(`#${inputId}`).element.closest('.recipient-field')
|
||||
return Array.from(field?.querySelectorAll('.recipient-chip__label') ?? []).map(
|
||||
@@ -105,10 +144,25 @@ describe('ComposePage', () => {
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
|
||||
'Re: 메일 서버 이전 일정 공유',
|
||||
)
|
||||
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
|
||||
expect(body).toContain('──────────')
|
||||
expect(body).toContain('이도윤 <doyun.lee@arasystem.it.kr> 작성:')
|
||||
expect(body).toContain('> 김아라님, 안녕하세요.')
|
||||
// 원문은 "원본 메일" 머리(보낸 사람 · 날짜 · 제목)와 함께 인용 막대 안에 (디자인 README "원문 인용")
|
||||
const quote = wrapper.get('#compose-body blockquote[data-mail-quote]')
|
||||
expect(quote.get('p[data-quote-header]').text()).toBe('원본 메일')
|
||||
expect(quote.get('table[data-quote-header]').text()).toContain('보낸 사람이도윤 <doyun.lee@arasystem.it.kr>')
|
||||
expect(quote.text()).toContain('김아라님, 안녕하세요.')
|
||||
expect(quote.text()).not.toContain('받는 사람')
|
||||
})
|
||||
|
||||
it('답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose?replyTo=m1', replyApi, true)
|
||||
await vi.waitFor(
|
||||
() => {
|
||||
if (document.activeElement?.id !== 'compose-body') throw new Error('아직 초점이 없다')
|
||||
},
|
||||
{ timeout: EDITOR_LOAD_TIMEOUT_MS },
|
||||
)
|
||||
|
||||
expect(editorOf(wrapper.element).state.selection.from).toBe(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('전체 답장(&all=1)은 원래 참조까지 채운다', async () => {
|
||||
@@ -130,7 +184,11 @@ describe('ComposePage', () => {
|
||||
cc: [],
|
||||
bcc: [],
|
||||
subject: 'Re: 메일 서버 이전 일정 공유',
|
||||
// 서식 본문과 같은 내용의 텍스트 — 인용은 "> " (계약 2절 "서식 본문")
|
||||
body: expect.stringContaining('> 김아라님, 안녕하세요.'),
|
||||
bodyHtml: expect.stringMatching(
|
||||
/^<div style="font-family: 'Malgun Gothic'.*<blockquote style="margin: 24px 0 0; .*원본 메일.*<\/div>$/,
|
||||
),
|
||||
attachments: [],
|
||||
inReplyTo: 'm1',
|
||||
})
|
||||
@@ -202,10 +260,13 @@ describe('ComposePage', () => {
|
||||
expect((wrapper.get('#compose-subject').element as HTMLInputElement).value).toBe(
|
||||
'Fwd: 메일 서버 이전 일정 공유',
|
||||
)
|
||||
const body = (wrapper.get('textarea').element as HTMLTextAreaElement).value
|
||||
expect(body).toContain('전달된 메일')
|
||||
expect(body).toContain('보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>')
|
||||
expect(body).toContain('김아라님, 안녕하세요.')
|
||||
// 전달 머리에는 받는 사람 · 참조도 있다
|
||||
const header = wrapper.get('#compose-body table[data-quote-header]').text()
|
||||
expect(bodyText(wrapper)).toContain('원본 메일')
|
||||
expect(header).toContain('보낸 사람이도윤 <doyun.lee@arasystem.it.kr>')
|
||||
expect(header).toContain('받는 사람김아라 <ara.kim@arasystem.it.kr>')
|
||||
expect(header).toContain('참조인프라팀 <infra@arasystem.it.kr>')
|
||||
expect(bodyText(wrapper)).toContain('김아라님, 안녕하세요.')
|
||||
expect(attachmentNames(wrapper)).toEqual(['체크리스트.xlsx', '견적.pdf'])
|
||||
})
|
||||
|
||||
@@ -399,6 +460,240 @@ describe('ComposePage', () => {
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
})
|
||||
|
||||
describe('서식 본문 (계약 2절 "서식 본문")', () => {
|
||||
const uploadApi: MockHandler = (request) => {
|
||||
if (request.method === 'POST' && request.url === '/mail-attachments') {
|
||||
const file = (request.data as FormData).get('file') as File
|
||||
return ok({ id: `up-${file.name}`, name: file.name, type: file.type, size: file.size }, 201)
|
||||
}
|
||||
return replyApi(request)
|
||||
}
|
||||
|
||||
async function addRecipient(wrapper: VueWrapper) {
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('doyun.lee@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
}
|
||||
|
||||
function uploadedNames(requests: MockRequest[]): string[] {
|
||||
return requests
|
||||
.filter((request) => request.method === 'POST' && request.url === '/mail-attachments')
|
||||
.map((request) => ((request.data as FormData).get('file') as File).name)
|
||||
}
|
||||
|
||||
it('쓴 서식은 서식 본문(bodyHtml)으로, 같은 내용은 텍스트(body)로 함께 보낸다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
|
||||
await addRecipient(wrapper)
|
||||
editorOf(wrapper.element).commands.setContent('<p>확인했습니다.</p><ol><li><p>DNS 낮추기</p></li></ol>')
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
|
||||
expect(payload?.body).toBe('확인했습니다.\n1. DNS 낮추기')
|
||||
expect(payload?.bodyHtml).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>' +
|
||||
'<ol style="margin: 0; padding-left: 24px"><li><p style="margin: 0">DNS 낮추기</p></li></ol></div>',
|
||||
)
|
||||
})
|
||||
|
||||
it('본문이 비었으면 서식 본문 없이 빈 텍스트로 보낸다 (텍스트 메일)', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi)
|
||||
await addRecipient(wrapper)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const payload = sendRequest(requests)?.data as Record<string, unknown> | undefined
|
||||
expect(payload?.body).toBe('')
|
||||
expect(payload).not.toHaveProperty('bodyHtml')
|
||||
})
|
||||
|
||||
it('본문에 붙여 넣은 그림은 "붙여넣은 이미지 1.png" 첨부로 올리고 알린다 — 본문에는 넣지 않는다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi)
|
||||
|
||||
wrapper
|
||||
.get('.ProseMirror')
|
||||
.element.dispatchEvent(pasteEvent({ files: [new File(['png'], 'image.png', { type: 'image/png' })] }))
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadedNames(requests)).toEqual(['붙여넣은 이미지 1.png'])
|
||||
expect(wrapper.findAll('.upload-item__name').map((item) => item.text())).toEqual(['붙여넣은 이미지 1.png'])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
'이미지는 본문 대신 첨부 파일로 올렸습니다.',
|
||||
])
|
||||
expect(wrapper.find('#compose-body img').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('파일을 끄는 동안 본문 위에 덮개를 보이고, 쓰기 화면 어디에 놓아도 첨부로 올린다 — 이미지가 아니면 알리지 않는다', async () => {
|
||||
// 끌기는 화면(window) 전체에서 받는다 — 문서에 붙여야 window 까지 올라간다
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
|
||||
const report = new File(['pdf'], '보고서.pdf', { type: 'application/pdf' })
|
||||
|
||||
wrapper.get('#compose-subject').element.dispatchEvent(fileDragEvent('dragenter'))
|
||||
await flushPromises()
|
||||
expect(wrapper.get('.mail-editor__drop').text()).toBe('놓으면 첨부 파일로 올립니다')
|
||||
|
||||
// 첨부 영역에 놓는다 (본문 밖)
|
||||
wrapper.get('.compose__attachments').element.dispatchEvent(fileDragEvent('drop', [report]))
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.mail-editor__drop').exists()).toBe(false)
|
||||
expect(uploadedNames(requests)).toEqual(['보고서.pdf'])
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
// 칸 사이를 지나도 깜빡이지 않는다 — 들어온 만큼 나가야(쓰기 화면 밖으로) 끈다
|
||||
const subjectInput = wrapper.get('#compose-subject').element
|
||||
const body = wrapper.get('.ProseMirror').element
|
||||
subjectInput.dispatchEvent(fileDragEvent('dragenter'))
|
||||
body.dispatchEvent(fileDragEvent('dragenter'))
|
||||
subjectInput.dispatchEvent(fileDragEvent('dragleave'))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.mail-editor__drop').exists()).toBe(true)
|
||||
|
||||
body.dispatchEvent(fileDragEvent('dragleave', [], { relatedTarget: null }))
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.mail-editor__drop').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('쓰기 칸(폼) 밖 빈 곳에 놓아도 첨부로 올리고 브라우저가 파일을 열지 않게 한다 — 화면을 떠나면 받지 않는다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
|
||||
const report = new File(['pdf'], '회의록.pdf', { type: 'application/pdf' })
|
||||
|
||||
const over = fileDragEvent('dragover')
|
||||
document.body.dispatchEvent(over)
|
||||
const drop = fileDragEvent('drop', [report])
|
||||
document.body.dispatchEvent(drop)
|
||||
await flushPromises()
|
||||
|
||||
expect(over.defaultPrevented).toBe(true)
|
||||
expect(drop.defaultPrevented).toBe(true)
|
||||
expect(uploadedNames(requests)).toEqual(['회의록.pdf'])
|
||||
|
||||
wrapper.unmount()
|
||||
const later = fileDragEvent('drop', [report])
|
||||
document.body.dispatchEvent(later)
|
||||
await flushPromises()
|
||||
expect(later.defaultPrevented).toBe(false)
|
||||
expect(uploadedNames(requests)).toEqual(['회의록.pdf'])
|
||||
})
|
||||
|
||||
it('붙여 넣은 그림이 사전 검사에 걸리면(허용 밖 형식) 올리지 않고 "첨부로 올렸습니다" 알림도 띄우지 않는다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi)
|
||||
|
||||
wrapper
|
||||
.get('.ProseMirror')
|
||||
.element.dispatchEvent(pasteEvent({ files: [new File(['bmp'], 'image.bmp', { type: 'image/bmp' })] }))
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadedNames(requests)).toEqual([])
|
||||
expect(wrapper.get('.upload-item--failed .upload-item__error').text()).toBe(ERROR_CODE_MESSAGES.FILE_001)
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
})
|
||||
|
||||
it('본문에 놓은 파일은 편집기가 넘긴 한 번만 올린다 (화면 전체의 놓기 처리는 건너뛴다)', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', uploadApi, true)
|
||||
const photo = new File(['png'], '현장.png', { type: 'image/png' })
|
||||
|
||||
wrapper.get('.ProseMirror').element.dispatchEvent(fileDragEvent('drop', [photo]))
|
||||
await flushPromises()
|
||||
|
||||
expect(uploadedNames(requests)).toEqual(['현장.png'])
|
||||
expect(useToastStore().toasts.map((toast) => toast.message)).toEqual([
|
||||
'이미지는 본문 대신 첨부 파일로 올렸습니다.',
|
||||
])
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('입력칸마다 같은 포커스 표시 — 받는 사람 · 참조 · 숨은참조 · 제목 행과 본문 편집 영역에 compose-field', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi)
|
||||
|
||||
expect(wrapper.findAll('.compose-field')).toHaveLength(5)
|
||||
expect(wrapper.get('#compose-subject').element.closest('.compose-field')).not.toBeNull()
|
||||
expect(wrapper.get('#compose-body').element.closest('.compose-field')).not.toBeNull()
|
||||
// 툴바(링크 팝오버의 입력칸)는 본문 선 밖이다 — 테두리 있는 칸은 고리를 쓴다
|
||||
expect(wrapper.get('[role="toolbar"]').element.closest('.compose-field')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('보내기 직전의 길이 — 제목 255 · 본문 텍스트 100,000 · 서식 500,000 (계약 2절)', () => {
|
||||
async function addRecipient(wrapper: VueWrapper) {
|
||||
const input = wrapper.get('#compose-to')
|
||||
await input.setValue('doyun.lee@arasystem.it.kr')
|
||||
await input.trigger('blur')
|
||||
}
|
||||
|
||||
it('내 글만으로 본문 한도를 넘으면 보내지 않고 본문 아래에 사전 문구(VAL_001) — 본문으로 초점, 고치면 내린다', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
|
||||
await addRecipient(wrapper)
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.setContent(`<p>${'가'.repeat(MAIL_BODY_MAX_LENGTH + 1)}</p>`)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
expect(wrapper.get('#compose-body-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#compose-body').attributes('aria-describedby')).toBe('compose-body-error')
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
expect(useToastStore().toasts).toEqual([])
|
||||
|
||||
editor.commands.insertContent('나')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('#compose-body-error').exists()).toBe(false)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('답장 원문이 길어도 맞춘 초안에 내 글을 쓰면 보낸다 — 남겨 둔 자리보다 길면 인용을 더 줄여(생략 표시) 보낸다', async () => {
|
||||
const longOriginal = { ...ORIGINAL, textBody: `${'긴 원문 줄입니다. '.repeat(10)}\n`.repeat(1_000) }
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose?replyTo=m1', (request) =>
|
||||
request.method === 'GET' && request.url === '/messages/m1' ? ok(longOriginal) : replyApi(request),
|
||||
)
|
||||
const mine = '내가 쓴 긴 답장입니다. '.repeat(600)
|
||||
editorOf(wrapper.element).commands.insertContentAt(1, mine)
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
const payload = sendRequest(requests)?.data as { body: string; bodyHtml: string } | undefined
|
||||
expect(payload?.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(payload?.bodyHtml.length).toBeLessThanOrEqual(MAIL_BODY_HTML_MAX_LENGTH)
|
||||
expect(payload?.body.startsWith(mine.trimEnd())).toBe(true)
|
||||
expect(payload?.body.endsWith(`> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('제목이 255자를 넘으면(붙여 넣기 등) 보내지 않고 제목 아래에 사전 문구 — 제목으로 초점', async () => {
|
||||
const { wrapper, requests } = await mountCompose('/mail/compose', replyApi, true)
|
||||
await addRecipient(wrapper)
|
||||
await wrapper.get('#compose-subject').setValue('제'.repeat(MAIL_SUBJECT_MAX_LENGTH + 1))
|
||||
|
||||
await wrapper.get('form').trigger('submit')
|
||||
await flushPromises()
|
||||
|
||||
expect(sendRequest(requests)).toBeUndefined()
|
||||
expect(wrapper.get('#compose-subject-error').text()).toBe(ERROR_CODE_MESSAGES.VAL_001)
|
||||
expect(wrapper.get('#compose-subject').attributes('aria-invalid')).toBe('true')
|
||||
expect(document.activeElement?.id).toBe('compose-subject')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('제목에서 Enter 를 누르면 본문의 커서 자리 그대로 본문으로 간다', async () => {
|
||||
const { wrapper } = await mountCompose('/mail/compose', replyApi, true)
|
||||
const editor = editorOf(wrapper.element)
|
||||
editor.commands.setContent('<p>첫 줄</p><p>둘째 줄</p>')
|
||||
editor.commands.setTextSelection(8)
|
||||
;(wrapper.get('#compose-subject').element as HTMLInputElement).focus()
|
||||
|
||||
await wrapper.get('#compose-subject').trigger('keydown', { key: 'Enter' })
|
||||
|
||||
expect(document.activeElement?.id).toBe('compose-body')
|
||||
expect(editor.state.selection.from).toBe(8)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
|
||||
describe('중복 발송 방지 키 (Idempotency-Key, 계약 2절)', () => {
|
||||
const UUID_V4 = /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/
|
||||
|
||||
|
||||
@@ -1,22 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
// 메일 쓰기 (디자인 원본 Compose) — /mail/compose
|
||||
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 인용 본문을 채운다.
|
||||
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 본문 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
|
||||
// 메일 쓰기 (디자인 원본 Compose · ComposeEditor) — /mail/compose
|
||||
// 답장: ?replyTo=<메일 id> (전체 답장은 &all=1) → 받는 사람 · "Re:" 제목 · 원문 인용을 채운다.
|
||||
// 전달: ?forward=<메일 id> → "Fwd:" 제목 · 원문 인용 · 원본 첨부(하나씩 뺄 수 있다)를 채운다. inReplyTo 는 보내지 않는다.
|
||||
// 본문은 서식 편집기(TipTap) — 이 화면에서만 불러온다(지연 로딩). 보낼 때 서식 본문(bodyHtml)과 같은 내용의 텍스트(body)를 함께 보낸다.
|
||||
// 편집기 청크를 못 받으면 본문 자리에 사전 문구(SYS_001)와 "다시 시도"를 보인다 (ComposeEditorLoadError).
|
||||
// 본문에 붙여 넣거나 끌어다 놓은 파일(이미지 포함)은 첨부로 올린다 — 본문에는 이미지를 넣지 않는다 (사용자 확정).
|
||||
// 화면 어디에 파일을 놓아도 첨부로 받는다 — 브라우저가 파일을 열어 쓰던 메일을 잃지 않게 (useWindowFileDrop).
|
||||
// 받는 사람 오류 문구는 에러 코드 사전(MAIL_002)에서만 온다 — 형식 오류(즉시)와 서버 거부 모두 같은 문구.
|
||||
import { computed, nextTick, ref, watch } from 'vue'
|
||||
// 보내기 직전에 제목 · 본문 길이를 최종 모양으로 잰다 — 넘으면 보내지 않고 그 칸 아래에 사전 문구(VAL_001).
|
||||
import { computed, defineAsyncComponent, nextTick, provide, ref, watch } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import AppIcon from '@/components/AppIcon.vue'
|
||||
import ComposeAttachmentList from '@/components/mail/ComposeAttachmentList.vue'
|
||||
import ComposeEditorLoadError from '@/components/mail/ComposeEditorLoadError.vue'
|
||||
import RecipientField from '@/components/mail/RecipientField.vue'
|
||||
import {
|
||||
MAIL_EDITOR_RETRY,
|
||||
loadMailEditor,
|
||||
type MailEditorHandle,
|
||||
} from '@/components/mail/editor/mail-editor-handle'
|
||||
import { getErrorMessage, toApiError } from '@/composables/useApi'
|
||||
import { useAttachmentUploads } from '@/composables/useAttachmentUploads'
|
||||
import { useMail } from '@/composables/useMail'
|
||||
import { useToast } from '@/composables/useToast'
|
||||
import { useWindowFileDrop } from '@/composables/useWindowFileDrop'
|
||||
import {
|
||||
COMPOSE_TITLES,
|
||||
MAIL_ATTACHMENT_ACCEPT,
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_MAX_ATTACHMENTS,
|
||||
MAIL_MAX_RECIPIENTS,
|
||||
MAIL_SENT_ACTION_LABEL,
|
||||
@@ -24,7 +35,9 @@ import {
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
type ComposeMode,
|
||||
} from '@/shared/constants/mail'
|
||||
import { IMAGE_AS_ATTACHMENT_MESSAGE } from '@/shared/constants/mail-editor'
|
||||
import type { SendMessagePayload } from '@/shared/types/mail'
|
||||
import { isImageFile } from '@/shared/utils/editor-files'
|
||||
import {
|
||||
createIdempotencyKey,
|
||||
shouldRenewSendKey,
|
||||
@@ -37,8 +50,20 @@ import { buildReplyDraft } from '@/shared/utils/mail-reply'
|
||||
import { useAuthStore } from '@/stores/auth.store'
|
||||
import { useMailStore } from '@/stores/mail.store'
|
||||
|
||||
/**
|
||||
* 서식 편집기 — TipTap 을 쓰기 화면에서만 내려받는다.
|
||||
* 못 받으면 본문 자리에 사전 문구와 "다시 시도" — 다시 시도하면 편집기를 새로 만들어 청크를 다시 받는다 (MAIL_EDITOR_RETRY).
|
||||
*/
|
||||
const MailEditor = defineAsyncComponent({
|
||||
loader: loadMailEditor,
|
||||
errorComponent: ComposeEditorLoadError,
|
||||
})
|
||||
|
||||
/** 받는 사람 오류 안내 문단 — 세 입력칸이 함께 가리킨다 */
|
||||
const RECIPIENT_ERROR_ID = 'compose-recipient-error'
|
||||
/** 길이 오류 안내 문단 — 제목 · 본문이 각각 가리킨다 */
|
||||
const SUBJECT_ERROR_ID = 'compose-subject-error'
|
||||
const BODY_ERROR_ID = 'compose-body-error'
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
@@ -68,7 +93,16 @@ const cc = ref<RecipientToken[]>([])
|
||||
const bcc = ref<RecipientToken[]>([])
|
||||
const showCopies = ref<boolean>(false)
|
||||
const subject = ref<string>('')
|
||||
const body = ref<string>('')
|
||||
/** 편집기의 처음 내용 — 답장·전달의 원문 인용 (편집기를 새로 만들 때만 읽는다) */
|
||||
const draftHtml = ref<string>('')
|
||||
/** 처음 내용을 본문 한도에 맞출지 — 답장·전달 초안 */
|
||||
const fitDraft = ref<boolean>(false)
|
||||
/** 양식을 새로 채울 때마다 바꿔 편집기를 새로 만든다 (실행 취소 기록도 새로 시작한다) */
|
||||
const editorKey = ref<number>(0)
|
||||
/** 편집기를 다 불러왔다 — 그 전에는 본문을 읽을 수 없어 보내지 않는다 */
|
||||
const editorReady = ref<boolean>(false)
|
||||
/** 편집기가 준비되면 본문 맨 위로 커서를 옮긴다 (답장) */
|
||||
let focusBodyWhenReady = false
|
||||
/** 답장할 원문을 불러오는 중 — 그동안 입력을 막아 미리 채운 값이 사용자 입력을 덮지 않게 한다 */
|
||||
const preparing = ref<boolean>(false)
|
||||
const sending = ref<boolean>(false)
|
||||
@@ -76,6 +110,8 @@ const sending = ref<boolean>(false)
|
||||
const attempted = ref<boolean>(false)
|
||||
/** 서버가 받는 사람을 거부했다 (MAIL_002) */
|
||||
const rejectedByServer = ref<boolean>(false)
|
||||
/** 보내기 직전에 잰 길이가 한도를 넘은 칸 — 제목 255자 · 본문(텍스트 100,000 · 서식 500,000, 인용을 다 줄여도) */
|
||||
const lengthError = ref<'subject' | 'body' | null>(null)
|
||||
/**
|
||||
* 중복 발송 방지 키 (계약 2절 Idempotency-Key) — 쓰기 화면마다 하나.
|
||||
* 양식을 새로 채울 때(새 화면·다른 쓰기 종류나 원본) 만들고, 같은 화면에서는 보내기 결과가
|
||||
@@ -87,7 +123,8 @@ let sendKey = createIdempotencyKey()
|
||||
const toField = ref<InstanceType<typeof RecipientField> | null>(null)
|
||||
const ccField = ref<InstanceType<typeof RecipientField> | null>(null)
|
||||
const bccField = ref<InstanceType<typeof RecipientField> | null>(null)
|
||||
const bodyRef = ref<HTMLTextAreaElement | null>(null)
|
||||
const editorRef = ref<MailEditorHandle | null>(null)
|
||||
const subjectInputRef = ref<HTMLInputElement | null>(null)
|
||||
const fileInputRef = ref<HTMLInputElement | null>(null)
|
||||
const attachButtonRef = ref<HTMLButtonElement | null>(null)
|
||||
const attachmentListRef = ref<InstanceType<typeof ComposeAttachmentList> | null>(null)
|
||||
@@ -127,10 +164,29 @@ const showRecipientError = computed(
|
||||
)
|
||||
/** 문구는 에러 코드 사전에서만 온다 */
|
||||
const recipientErrorMessage = getErrorMessage('MAIL_002')
|
||||
/** 길이가 넘으면 서버도 VAL_001 로 거절한다 — 같은 문구를 보내기 전에 보인다 */
|
||||
const lengthErrorMessage = getErrorMessage('VAL_001')
|
||||
/** 서버가 찾지 못한 첨부(MAIL_004)가 남아 있으면 보내지 않는다 — 모르는 사이 첨부 없이 나가지 않게 */
|
||||
const canSend = computed(
|
||||
() => !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
|
||||
() =>
|
||||
editorReady.value && !preparing.value && !sending.value && !isUploading.value && !hasMissing.value,
|
||||
)
|
||||
/** 편집기를 새로 만든다 — 다음에 그릴 때 draftHtml 로 시작한다 */
|
||||
function renewEditor() {
|
||||
editorKey.value += 1
|
||||
editorReady.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* 편집기 청크를 못 받았을 때의 "다시 시도" — 편집기를 새로 만들어 다시 받는다.
|
||||
* 누른 단추가 편집기로 바뀌며 사라지므로, 준비되면 초점을 본문으로 옮긴다 (다른 칸에 초점이 없을 때만).
|
||||
*/
|
||||
function retryEditor() {
|
||||
focusBodyWhenReady = true
|
||||
renewEditor()
|
||||
}
|
||||
|
||||
provide(MAIL_EDITOR_RETRY, retryEditor)
|
||||
|
||||
function resetForm() {
|
||||
to.value = []
|
||||
@@ -138,9 +194,13 @@ function resetForm() {
|
||||
bcc.value = []
|
||||
showCopies.value = false
|
||||
subject.value = ''
|
||||
body.value = ''
|
||||
draftHtml.value = ''
|
||||
fitDraft.value = false
|
||||
focusBodyWhenReady = false
|
||||
renewEditor()
|
||||
attempted.value = false
|
||||
rejectedByServer.value = false
|
||||
lengthError.value = null
|
||||
resetUploads()
|
||||
// 양식을 새로 채우면 다른 메일이다
|
||||
sendKey = createIdempotencyKey()
|
||||
@@ -159,7 +219,7 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
if (sourceMode === 'forward') {
|
||||
const draft = buildForwardDraft(original)
|
||||
subject.value = draft.subject
|
||||
body.value = draft.body
|
||||
draftHtml.value = draft.html
|
||||
// 원본 첨부는 다시 올리지 않고 id 를 그대로 보낸다 — 목록에서 하나씩 뺄 수 있다
|
||||
addExisting(draft.attachments)
|
||||
} else {
|
||||
@@ -168,25 +228,54 @@ async function prepareFrom(messageId: string, sourceMode: Exclude<ComposeMode, '
|
||||
cc.value = draft.cc.map(tokenFromAddress)
|
||||
showCopies.value = cc.value.length > 0
|
||||
subject.value = draft.subject
|
||||
body.value = draft.body
|
||||
draftHtml.value = draft.html
|
||||
// 답장은 커서를 본문 맨 위 첫 줄(인용 위)에 둔다 — 편집기가 준비되면
|
||||
focusBodyWhenReady = true
|
||||
}
|
||||
// 한도(텍스트 100,000 · 서식 500,000)를 넘는 원문은 편집기가 인용을 줄인다 (계약 2절 "답장·전달 초안의 길이")
|
||||
fitDraft.value = true
|
||||
renewEditor()
|
||||
} catch {
|
||||
// 원문을 못 불러왔다 — 알림은 인터셉터가 띄웠다. 빈 양식으로 쓰게 둔다
|
||||
} finally {
|
||||
if (seq === prepareSeq) preparing.value = false
|
||||
}
|
||||
if (seq !== prepareSeq) return
|
||||
await nextTick()
|
||||
if (sourceMode === 'forward') {
|
||||
// 전달은 받는 사람부터 정한다
|
||||
await nextTick()
|
||||
toField.value?.focus()
|
||||
return
|
||||
}
|
||||
// 답장은 커서를 본문 맨 앞(인용 위)에 둔다
|
||||
bodyRef.value?.focus()
|
||||
bodyRef.value?.setSelectionRange(0, 0)
|
||||
}
|
||||
|
||||
/** 아직 어느 칸에도 초점이 없다 — 사용자가 받는 사람 · 제목에 쓰기 시작했으면 본문으로 옮기지 않는다 (한글 조합이 끊긴다) */
|
||||
function isFocusFree(): boolean {
|
||||
const active = document.activeElement
|
||||
return active === null || active === document.body
|
||||
}
|
||||
|
||||
function onEditorReady() {
|
||||
editorReady.value = true
|
||||
if (!focusBodyWhenReady) return
|
||||
focusBodyWhenReady = false
|
||||
void nextTick(() => {
|
||||
if (isFocusFree()) editorRef.value?.focus('start')
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문에 붙여 넣거나 끌어다 놓은 파일 — 첨부로 올리고, 이미지를 첨부로 돌렸을 때만 알린다 (디자인 README "본문 이미지 → 첨부 파일").
|
||||
* 사전 검사(형식 · 크기 · 개수)에 걸린 이미지는 올리지 않으므로 알리지 않는다 — 첨부 목록에 실패로 보인다.
|
||||
*/
|
||||
function attachFromBody(files: File[]) {
|
||||
if (files.length === 0) return
|
||||
const accepted = addFiles(files)
|
||||
if (accepted.some(isImageFile)) toast.success(IMAGE_AS_ATTACHMENT_MESSAGE)
|
||||
}
|
||||
|
||||
// 쓰기 화면이 떠 있는 동안 어디에 놓아도 첨부로 올린다 — 끄는 동안 본문 위에 덮개를 보인다
|
||||
const { dragging } = useWindowFileDrop(attachFromBody)
|
||||
|
||||
function commitRecipientInputs() {
|
||||
toField.value?.commit()
|
||||
ccField.value?.commit()
|
||||
@@ -217,14 +306,31 @@ async function onSubmit() {
|
||||
toField.value?.focus()
|
||||
return
|
||||
}
|
||||
if (!canSend.value) return
|
||||
if (!canSend.value || !editorRef.value) return
|
||||
|
||||
// 길이는 보낼 모양 그대로 잰다 (계약 2절 "발송 본문" — 넘기면 서버가 VAL_001 로 거절한다)
|
||||
const subjectText = subject.value.trim()
|
||||
if (subjectText.length > MAIL_SUBJECT_MAX_LENGTH) {
|
||||
lengthError.value = 'subject'
|
||||
subjectInputRef.value?.focus()
|
||||
return
|
||||
}
|
||||
// 서식 본문과 같은 내용의 텍스트를 함께 보낸다 — 본문이 비었으면 텍스트 메일 (계약 2절 "서식 본문").
|
||||
// 한도를 넘으면 편집기가 원문 인용을 더 줄인다. 내 글만으로 넘으면 null — 보내지 않고 본문 아래에 알린다
|
||||
const content = editorRef.value.getContentForSend()
|
||||
if (content === null) {
|
||||
lengthError.value = 'body'
|
||||
editorRef.value.focus()
|
||||
return
|
||||
}
|
||||
lengthError.value = null
|
||||
const payload: SendMessagePayload = {
|
||||
to: to.value.map((token) => token.address),
|
||||
cc: cc.value.map((token) => token.address),
|
||||
bcc: bcc.value.map((token) => token.address),
|
||||
subject: subject.value.trim(),
|
||||
body: body.value,
|
||||
subject: subjectText,
|
||||
body: content.text,
|
||||
...(content.html ? { bodyHtml: content.html } : {}),
|
||||
// 새로 올린 파일은 업로드 응답 id, 전달한 원본 첨부는 원본 id 를 그대로 보낸다 (계약 2절 "전달")
|
||||
attachments: uploaded.value.map(({ id, name }) => ({ id, name })),
|
||||
// 스레드 연결은 답장만 — 전달에는 inReplyTo 를 넣지 않는다
|
||||
@@ -268,11 +374,16 @@ async function onSubmit() {
|
||||
}
|
||||
}
|
||||
|
||||
/** 제목에서 Enter 는 메일을 보내지 않고 본문으로 넘어간다 */
|
||||
/** 제목에서 Enter 는 메일을 보내지 않고 본문으로 넘어간다 — 커서는 본문에 있던 자리 그대로 (처음이면 맨 앞) */
|
||||
function onSubjectEnter(event: KeyboardEvent) {
|
||||
if (event.isComposing) return
|
||||
event.preventDefault()
|
||||
bodyRef.value?.focus()
|
||||
editorRef.value?.focus()
|
||||
}
|
||||
|
||||
/** 길이 안내는 그 칸을 고치면 내린다 */
|
||||
function clearLengthError(field: 'subject' | 'body') {
|
||||
if (lengthError.value === field) lengthError.value = null
|
||||
}
|
||||
|
||||
function pickFiles() {
|
||||
@@ -389,13 +500,14 @@ watch(allRecipients, () => {
|
||||
<span>{{ recipientErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<div class="compose__row">
|
||||
<div class="compose__row compose-field">
|
||||
<label
|
||||
for="compose-subject"
|
||||
class="compose__label"
|
||||
>제목</label>
|
||||
<input
|
||||
id="compose-subject"
|
||||
ref="subjectInputRef"
|
||||
v-model="subject"
|
||||
type="text"
|
||||
class="compose__subject"
|
||||
@@ -403,20 +515,52 @@ watch(allRecipients, () => {
|
||||
autocomplete="off"
|
||||
:maxlength="MAIL_SUBJECT_MAX_LENGTH"
|
||||
:disabled="preparing"
|
||||
:aria-invalid="lengthError === 'subject'"
|
||||
:aria-describedby="lengthError === 'subject' ? SUBJECT_ERROR_ID : undefined"
|
||||
@keydown.enter="onSubjectEnter"
|
||||
@input="clearLengthError('subject')"
|
||||
>
|
||||
</div>
|
||||
<p
|
||||
v-if="lengthError === 'subject'"
|
||||
:id="SUBJECT_ERROR_ID"
|
||||
class="compose__alert"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ lengthErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<textarea
|
||||
ref="bodyRef"
|
||||
v-model="body"
|
||||
class="compose__body"
|
||||
aria-label="본문"
|
||||
rows="13"
|
||||
placeholder="내용을 입력하세요"
|
||||
:maxlength="MAIL_BODY_MAX_LENGTH"
|
||||
:disabled="preparing"
|
||||
/>
|
||||
<!-- 편집기를 불러오거나 원문을 채우는 동안에도 자리를 지켜 아래 줄이 뛰지 않게 한다 -->
|
||||
<div class="compose__editor">
|
||||
<MailEditor
|
||||
v-if="!preparing"
|
||||
ref="editorRef"
|
||||
:key="editorKey"
|
||||
:initial-html="draftHtml"
|
||||
:fit-to-limits="fitDraft"
|
||||
:dragging="dragging"
|
||||
:described-by="lengthError === 'body' ? BODY_ERROR_ID : null"
|
||||
@ready="onEditorReady"
|
||||
@files="attachFromBody"
|
||||
@change="clearLengthError('body')"
|
||||
/>
|
||||
</div>
|
||||
<p
|
||||
v-if="lengthError === 'body'"
|
||||
:id="BODY_ERROR_ID"
|
||||
class="compose__alert"
|
||||
role="alert"
|
||||
>
|
||||
<AppIcon
|
||||
name="alert-circle"
|
||||
:size="15"
|
||||
/>
|
||||
<span>{{ lengthErrorMessage }}</span>
|
||||
</p>
|
||||
|
||||
<div class="compose__attachments">
|
||||
<div class="compose__attach-bar">
|
||||
@@ -587,33 +731,23 @@ watch(allRecipients, () => {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.compose__body {
|
||||
/*
|
||||
* 본문 서식 편집기 자리 — 툴바(52) + 편집 영역(300) + 아래 선.
|
||||
* 편집기를 내려받는 동안에도 같은 높이를 둔다. 포커스는 편집 영역의 아래 선이다 (.compose-field, styles/base.css).
|
||||
*/
|
||||
.compose__editor {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-height: 18.75rem;
|
||||
max-width: var(--compose-body-max-width);
|
||||
padding: var(--space-16) var(--content-inline);
|
||||
border: 0;
|
||||
resize: vertical;
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text-body);
|
||||
font-size: var(--font-size-body);
|
||||
line-height: var(--line-height-body);
|
||||
}
|
||||
|
||||
/* 테두리 없는 넓은 입력칸이라 초점 표시는 1px 로 가볍게, 안쪽에 둔다 */
|
||||
.compose__subject:focus-visible,
|
||||
.compose__body:focus-visible {
|
||||
outline-width: var(--border-width);
|
||||
outline-offset: calc(var(--border-width) * -1);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: calc(var(--size-editor-toolbar) + var(--size-editor-body-min) + var(--border-width));
|
||||
}
|
||||
|
||||
/* 위 선은 없다 — 편집 영역의 아래 선이 경계다 (디자인 README "글자 입력칸 포커스") */
|
||||
.compose__attachments {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-8);
|
||||
padding: var(--space-10) var(--content-inline) var(--space-14);
|
||||
border-top: var(--border-width) solid var(--color-border-subtle);
|
||||
}
|
||||
|
||||
.compose__attach-bar {
|
||||
|
||||
@@ -34,7 +34,15 @@ const rect = (x: number, y: number, width: number, height: number, radius = 0):
|
||||
},
|
||||
})
|
||||
|
||||
/** 채운 점 — 목록 글머리 · 더보기 (선이 아니라 면으로 그린다) */
|
||||
const dot = (cx: number, cy: number, r: number): IconShape => ({
|
||||
tag: 'circle',
|
||||
attrs: { cx: String(cx), cy: String(cy), r: String(r), fill: 'currentColor', stroke: 'none' },
|
||||
})
|
||||
|
||||
const ALERT_DOT = [line(12, 8, 12, 12), line(12, 16, 12.01, 16)]
|
||||
/** 정렬·들여쓰기 아이콘의 오른쪽 세 줄 */
|
||||
const INDENT_LINES = [line(21, 12, 11, 12), line(21, 6, 11, 6), line(21, 18, 11, 18)]
|
||||
|
||||
export const ICONS = {
|
||||
mail: [
|
||||
@@ -131,6 +139,72 @@ export const ICONS = {
|
||||
'arrow-left': [line(19, 12, 5, 12), polyline('12 19 5 12 12 5')],
|
||||
'chevron-left': [polyline('15 18 9 12 15 6')],
|
||||
'chevron-right': [polyline('9 18 15 12 9 6')],
|
||||
'chevron-down': [polyline('6 9 12 15 18 9')],
|
||||
|
||||
// ── 서식 편집기 툴바 (디자인 원본 Compose · ComposeEditor) ──
|
||||
undo: [path('M9 14 4 9l5-5'), path('M4 9h10.5a5.5 5.5 0 0 1 0 11H11')],
|
||||
redo: [path('m15 14 5-5-5-5'), path('M20 9H9.5a5.5 5.5 0 0 0 0 11H13')],
|
||||
bold: [path('M6 4h8a4 4 0 0 1 0 8H6z'), path('M6 12h9a4 4 0 0 1 0 8H6z')],
|
||||
italic: [line(19, 4, 10, 4), line(14, 20, 5, 20), line(15, 4, 9, 20)],
|
||||
underline: [path('M6 3v7a6 6 0 0 0 12 0V3'), line(4, 21, 20, 21)],
|
||||
strikethrough: [path('M16 4H9a3 3 0 0 0-2.83 4'), path('M14 12a4 4 0 0 1 0 8H6'), line(4, 12, 20, 12)],
|
||||
'list-bullet': [
|
||||
line(9, 6, 20, 6),
|
||||
line(9, 12, 20, 12),
|
||||
line(9, 18, 20, 18),
|
||||
dot(4.5, 6, 1.2),
|
||||
dot(4.5, 12, 1.2),
|
||||
dot(4.5, 18, 1.2),
|
||||
],
|
||||
'list-ordered': [
|
||||
line(10, 6, 21, 6),
|
||||
line(10, 12, 21, 12),
|
||||
line(10, 18, 21, 18),
|
||||
path('M4 6h1v4'),
|
||||
path('M4 10h2'),
|
||||
path('M6 18H4c0-1 2-2 2-3s-1-1.5-2-1'),
|
||||
],
|
||||
link: [
|
||||
path('M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71'),
|
||||
path('M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71'),
|
||||
],
|
||||
unlink: [
|
||||
path('m18.84 12.25 1.72-1.71a5 5 0 0 0-7.07-7.07l-1.72 1.71'),
|
||||
path('m5.17 11.75-1.71 1.71a5 5 0 0 0 7.07 7.07l1.71-1.71'),
|
||||
line(8, 2, 8, 5),
|
||||
line(2, 8, 5, 8),
|
||||
line(16, 19, 16, 22),
|
||||
line(19, 16, 22, 16),
|
||||
],
|
||||
'more-horizontal': [dot(5, 12, 1.6), dot(12, 12, 1.6), dot(19, 12, 1.6)],
|
||||
'align-left': [line(17, 10, 3, 10), line(21, 6, 3, 6), line(21, 14, 3, 14), line(17, 18, 3, 18)],
|
||||
'align-center': [line(18, 10, 6, 10), line(21, 6, 3, 6), line(21, 14, 3, 14), line(18, 18, 6, 18)],
|
||||
'align-right': [line(21, 10, 7, 10), line(21, 6, 3, 6), line(21, 14, 3, 14), line(21, 18, 7, 18)],
|
||||
outdent: [polyline('7 8 3 12 7 16'), ...INDENT_LINES],
|
||||
indent: [polyline('3 8 7 12 3 16'), ...INDENT_LINES],
|
||||
quote: [path('M17 6H3'), path('M21 12H8'), path('M21 18H8'), path('M3 12v6')],
|
||||
table: [rect(3, 3, 18, 18, 2), line(3, 9, 21, 9), line(3, 15, 21, 15), line(9, 3, 9, 21), line(15, 3, 15, 21)],
|
||||
'horizontal-rule': [line(3, 12, 21, 12), polyline('8 8 12 4 16 8'), polyline('16 16 12 20 8 16')],
|
||||
'clear-format': [
|
||||
path('M4 7V4h16v3'),
|
||||
path('M5 20h6'),
|
||||
path('M13 4 8 20'),
|
||||
path('m15 15 5 5'),
|
||||
path('m20 15-5 5'),
|
||||
],
|
||||
/** 더보기 메뉴의 글꼴 */
|
||||
type: [polyline('4 7 4 4 20 4 20 7'), line(9, 20, 15, 20), line(12, 4, 12, 20)],
|
||||
/** 더보기 메뉴의 글자 크기 */
|
||||
'font-size': [
|
||||
path('M3 7V5h10v2'),
|
||||
path('M8 5v14'),
|
||||
path('M6 19h4'),
|
||||
path('M14 12v-1h7v1'),
|
||||
path('M17.5 11v8'),
|
||||
path('M16 19h3'),
|
||||
],
|
||||
/** 배경 색 "없음" 견본의 빗금 */
|
||||
slash: [line(5, 19, 19, 5)],
|
||||
} as const satisfies Record<string, readonly IconShape[]>
|
||||
|
||||
export type IconName = keyof typeof ICONS
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
// 메일 쓰기 서식 편집기 상수 — 디자인 원본 Compose · ComposeEditor (docs/design/webmail README "메일 쓰기")
|
||||
// 보내는 HTML 에 그대로 들어가는 값(글꼴 스택·색)은 여기 한 곳에서만 정한다. 화면 CSS 는 tokens.css 의 같은 값을 쓴다.
|
||||
import {
|
||||
currentPlatform,
|
||||
formatShortcut,
|
||||
isApplePlatform,
|
||||
type ShortcutKeys,
|
||||
type ShortcutText,
|
||||
} from '@/shared/utils/keyboard-shortcut'
|
||||
|
||||
/** 화면에 하나뿐인 본문 편집 영역의 id — 툴바의 aria-controls 가 가리킨다 */
|
||||
export const MAIL_EDITOR_BODY_ID = 'compose-body'
|
||||
|
||||
/**
|
||||
* 본문 기본 글꼴 — 맑은 고딕 스택 (사용자 확정 2026-10-27). 편집기 안 모양을 받는 사람이 보는 모양과 같게 한다.
|
||||
* 보낼 때 본문 전체를 이 글꼴·14px 의 div 하나로 감싼다 (계약 2절 "서식 본문"). tokens.css 의 --font-family-mail-body 와 같은 값.
|
||||
*/
|
||||
export const MAIL_BODY_FONT_FAMILY = "'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif"
|
||||
/** 본문 기본 글자 크기(px) */
|
||||
export const MAIL_BODY_FONT_SIZE_PX = 14
|
||||
/**
|
||||
* 본문 줄 간격 (단위 없는 배수) — 감싸는 div 에 함께 넣어 받는 쪽도 편집기 화면과 같은 줄 간격으로 본다.
|
||||
* tokens.css 의 --line-height-mail-body 와 같은 값 (편집 영역이 그 변수를 쓴다 — 바꾸면 둘을 함께 바꾼다).
|
||||
*/
|
||||
export const MAIL_BODY_LINE_HEIGHT = 1.75
|
||||
|
||||
/** 서식 본문(bodyHtml) 길이 한도 — 계약 2절 "발송 본문" (넘기면 400 VAL_001) */
|
||||
export const MAIL_BODY_HTML_MAX_LENGTH = 500_000
|
||||
|
||||
/** 빈 본문 안내 (디자인 원본 ComposeEditor "빈 본문") */
|
||||
export const EDITOR_PLACEHOLDER = '내용을 입력하세요'
|
||||
|
||||
// ── 글꼴 · 글자 크기 ─────────────────────────────────────
|
||||
|
||||
export interface EditorFont {
|
||||
id: string
|
||||
label: string
|
||||
/** font-family 값 — 기본 글꼴은 지정을 지우므로 화면의 목록 미리보기에만 쓴다 */
|
||||
family: string
|
||||
/** 첫 항목 — 고르면 글꼴 지정을 지운다 (감싸는 div 의 기본 글꼴을 따른다) */
|
||||
isBase?: boolean
|
||||
}
|
||||
|
||||
/** 받는 쪽에 흔히 있는 글꼴만 (디자인 README "글꼴") — 한글 4 | 영문 5 */
|
||||
export const EDITOR_FONTS_KO: readonly EditorFont[] = [
|
||||
{ id: 'malgun', label: '맑은 고딕', family: MAIL_BODY_FONT_FAMILY, isBase: true },
|
||||
{ id: 'gulim', label: '굴림', family: "Gulim, '굴림', sans-serif" },
|
||||
{ id: 'dotum', label: '돋움', family: "Dotum, '돋움', sans-serif" },
|
||||
{ id: 'batang', label: '바탕', family: "Batang, '바탕', serif" },
|
||||
]
|
||||
export const EDITOR_FONTS_EN: readonly EditorFont[] = [
|
||||
{ id: 'arial', label: 'Arial', family: 'Arial, sans-serif' },
|
||||
{ id: 'verdana', label: 'Verdana', family: 'Verdana, sans-serif' },
|
||||
{ id: 'georgia', label: 'Georgia', family: 'Georgia, serif' },
|
||||
{ id: 'times', label: 'Times New Roman', family: "'Times New Roman', serif" },
|
||||
{ id: 'courier', label: 'Courier New', family: "'Courier New', monospace" },
|
||||
]
|
||||
export const EDITOR_FONTS: readonly EditorFont[] = [...EDITOR_FONTS_KO, ...EDITOR_FONTS_EN]
|
||||
|
||||
/** 글자 크기(px) — 제목 스타일은 두지 않는다. 14 가 기본(고르면 크기 지정을 지운다) */
|
||||
export const EDITOR_FONT_SIZES: readonly number[] = [12, 13, 14, 16, 18, 20, 24, 32]
|
||||
|
||||
/** 고른 글자의 글꼴·크기가 섞였을 때 보이는 표시 */
|
||||
export const EDITOR_MIXED_VALUE = '–'
|
||||
|
||||
// ── 색 (8색씩 — 디자인 README "색 팔레트") ─────────────────
|
||||
|
||||
export interface EditorColor {
|
||||
id: string
|
||||
name: string
|
||||
/** null 이면 지정을 지운다 (글자 색 "기본" · 배경 색 "없음") */
|
||||
value: string | null
|
||||
/** 견본에 보이는 색 */
|
||||
swatch: string
|
||||
/** 견본 위 ✓ 색 — 어두운 견본에는 밝은 ✓ */
|
||||
checkTone: 'light' | 'dark'
|
||||
/** 지우는 견본("없음")에 빗금을 긋는다 */
|
||||
slashed?: boolean
|
||||
}
|
||||
|
||||
/** 글자 색 — 모두 흰 바탕 4.5:1 이상. "기본"은 글자 색을 지운다 */
|
||||
export const EDITOR_TEXT_COLORS: readonly EditorColor[] = [
|
||||
{ id: 'default', name: '기본', value: null, swatch: '#1F2733', checkTone: 'light' },
|
||||
{ id: 'gray', name: '회색', value: '#667085', swatch: '#667085', checkTone: 'light' },
|
||||
{ id: 'red', name: '빨강', value: '#B42318', swatch: '#B42318', checkTone: 'light' },
|
||||
{ id: 'orange', name: '주황', value: '#B54708', swatch: '#B54708', checkTone: 'light' },
|
||||
{ id: 'green', name: '초록', value: '#067647', swatch: '#067647', checkTone: 'light' },
|
||||
{ id: 'teal', name: '청록', value: '#0E7C86', swatch: '#0E7C86', checkTone: 'light' },
|
||||
{ id: 'blue', name: '파랑', value: '#175CD3', swatch: '#175CD3', checkTone: 'light' },
|
||||
{ id: 'purple', name: '보라', value: '#6941C6', swatch: '#6941C6', checkTone: 'light' },
|
||||
]
|
||||
|
||||
/** 배경 색 — 본문 글자(#1F2733)가 10:1 이상 남는 옅은 색. "없음"은 배경 색을 지운다 */
|
||||
export const EDITOR_BACKGROUND_COLORS: readonly EditorColor[] = [
|
||||
{ id: 'none', name: '없음', value: null, swatch: '#FFFFFF', checkTone: 'dark', slashed: true },
|
||||
{ id: 'yellow', name: '노랑', value: '#FFF1A8', swatch: '#FFF1A8', checkTone: 'dark' },
|
||||
{ id: 'orange', name: '주황', value: '#FDE4CC', swatch: '#FDE4CC', checkTone: 'dark' },
|
||||
{ id: 'pink', name: '분홍', value: '#FBDCE3', swatch: '#FBDCE3', checkTone: 'dark' },
|
||||
{ id: 'green', name: '초록', value: '#D9F2DF', swatch: '#D9F2DF', checkTone: 'dark' },
|
||||
{ id: 'sky', name: '하늘', value: '#D6E8FB', swatch: '#D6E8FB', checkTone: 'dark' },
|
||||
{ id: 'purple', name: '보라', value: '#E9E0FA', swatch: '#E9E0FA', checkTone: 'dark' },
|
||||
{ id: 'gray', name: '회색', value: '#E9EBEF', swatch: '#E9EBEF', checkTone: 'dark' },
|
||||
]
|
||||
|
||||
/** 팔레트 밖의 색(붙여 넣은 글 등)일 때 이름 */
|
||||
export const EDITOR_OTHER_COLOR_NAME = '다른 색'
|
||||
|
||||
// ── 들여쓰기 · 표 ─────────────────────────────────────
|
||||
|
||||
/** 들여쓰기 한 단계(px) — 보낼 때 문단의 margin-left 가 된다 (서버 정화 허용 목록) */
|
||||
export const EDITOR_INDENT_STEP_PX = 40
|
||||
export const EDITOR_MAX_INDENT = 8
|
||||
|
||||
/** 표 넣기 격자 — 8열 × 6행 (디자인 원본 ComposeEditor "표 넣기") */
|
||||
export const TABLE_PICKER_COLUMNS = 8
|
||||
export const TABLE_PICKER_ROWS = 6
|
||||
/** 처음 열었을 때 골라 둔 크기 */
|
||||
export const TABLE_PICKER_DEFAULT = { rows: 3, cols: 4 } as const
|
||||
|
||||
// ── 단축키 (디자인 README "단축키") ─────────────────────
|
||||
|
||||
/** 화면에 보이는 글자(label)와 aria-keyshortcuts 값(aria) */
|
||||
export type EditorShortcut = ShortcutText
|
||||
|
||||
interface ShortcutDefinition {
|
||||
/** 키 이름 — 'Mod' 는 맥에서 ⌘, 그 밖에서는 Ctrl (TipTap 의 Mod 와 같다) */
|
||||
keys: ShortcutKeys
|
||||
/** 맥에서만 다른 키 — 다시 실행은 맥의 관례대로 ⇧⌘Z (편집기는 ⌘Y 도 받는다) */
|
||||
appleKeys?: ShortcutKeys
|
||||
}
|
||||
|
||||
const SHORTCUT_DEFINITIONS = {
|
||||
undo: { keys: ['Mod', 'Z'] },
|
||||
redo: { keys: ['Mod', 'Y'], appleKeys: ['Mod', 'Shift', 'Z'] },
|
||||
bold: { keys: ['Mod', 'B'] },
|
||||
italic: { keys: ['Mod', 'I'] },
|
||||
underline: { keys: ['Mod', 'U'] },
|
||||
strike: { keys: ['Mod', 'Shift', 'S'] },
|
||||
bulletList: { keys: ['Mod', 'Shift', '8'] },
|
||||
orderedList: { keys: ['Mod', 'Shift', '7'] },
|
||||
alignLeft: { keys: ['Mod', 'Shift', 'L'] },
|
||||
alignCenter: { keys: ['Mod', 'Shift', 'E'] },
|
||||
alignRight: { keys: ['Mod', 'Shift', 'R'] },
|
||||
outdent: { keys: ['Mod', '['] },
|
||||
indent: { keys: ['Mod', ']'] },
|
||||
blockquote: { keys: ['Mod', 'Shift', 'B'] },
|
||||
link: { keys: ['Mod', 'K'] },
|
||||
clearFormatting: { keys: ['Mod', '\\'] },
|
||||
} as const satisfies Record<string, ShortcutDefinition>
|
||||
|
||||
export type EditorShortcutName = keyof typeof SHORTCUT_DEFINITIONS
|
||||
|
||||
/** 플랫폼에 맞는 단축키 표기 — 윈도우는 "Ctrl+B", 맥은 "⌘B" */
|
||||
export function buildEditorShortcuts(apple: boolean): Record<EditorShortcutName, EditorShortcut> {
|
||||
const entries = Object.entries(SHORTCUT_DEFINITIONS) as [EditorShortcutName, ShortcutDefinition][]
|
||||
return Object.fromEntries(
|
||||
entries.map(([name, definition]) => [
|
||||
name,
|
||||
formatShortcut(apple && definition.appleKeys ? definition.appleKeys : definition.keys, apple),
|
||||
]),
|
||||
) as Record<EditorShortcutName, EditorShortcut>
|
||||
}
|
||||
|
||||
/** 지금 브라우저의 단축키 표기 */
|
||||
export const EDITOR_SHORTCUTS = buildEditorShortcuts(isApplePlatform(currentPlatform()))
|
||||
|
||||
// ── 본문 이미지 → 첨부 (디자인 README "본문 이미지 → 첨부 파일") ──────────
|
||||
|
||||
/** 붙여 넣은 이미지 이름 앞부분 — "붙여넣은 이미지 1.png" */
|
||||
export const PASTED_IMAGE_NAME_PREFIX = '붙여넣은 이미지'
|
||||
/** 이미지를 첨부로 돌렸을 때만 띄우는 성공 알림 */
|
||||
export const IMAGE_AS_ATTACHMENT_MESSAGE = '이미지는 본문 대신 첨부 파일로 올렸습니다.'
|
||||
/** 끌어다 놓는 동안 본문 위 덮개 글 */
|
||||
export const DROP_TO_ATTACH_LABEL = '놓으면 첨부 파일로 올립니다'
|
||||
|
||||
// ── 원문 인용 (디자인 README "답장 · 전달의 원문 인용") ──────────────
|
||||
|
||||
/** 인용 머리 — 답장 · 전달 모두 */
|
||||
export const MAIL_QUOTE_HEADING = '원본 메일'
|
||||
@@ -110,11 +110,7 @@ export const MAIL_IDEMPOTENCY_HEADER = 'Idempotency-Key'
|
||||
// ── 답장 · 전달 ─────────────────────────────────────────
|
||||
export const MAIL_REPLY_PREFIX = 'Re: '
|
||||
export const MAIL_FORWARD_PREFIX = 'Fwd: '
|
||||
/** 답장·전달 본문에서 내 글과 원문을 나누는 선 (디자인 원본 Compose) */
|
||||
export const MAIL_QUOTE_SEPARATOR = '──────────'
|
||||
/** 전달 본문의 원문 머리 제목 */
|
||||
export const MAIL_FORWARD_HEADING = '전달된 메일'
|
||||
/** 답장 인용 줄 머리 */
|
||||
/** 텍스트 본문(body)의 인용 줄 머리 — 서식 본문의 인용 막대에 해당한다 (계약 2절 "서식 본문") */
|
||||
export const MAIL_QUOTE_LINE_PREFIX = '> '
|
||||
/**
|
||||
* 답장·전달 초안의 인용(원문)을 본문 한도에 맞춰 줄였을 때 끝에 두는 표시 (계약 2절 "답장·전달 초안의 길이")
|
||||
|
||||
@@ -100,8 +100,10 @@ export interface SendMessagePayload {
|
||||
cc: Address[]
|
||||
bcc: Address[]
|
||||
subject: string
|
||||
/** 일반 텍스트 */
|
||||
/** 일반 텍스트 — 서식 본문을 보낼 때는 같은 내용의 대체 본문 (목록 "- "·"1. ", 인용 "> ") */
|
||||
body: string
|
||||
/** 서식 본문 (계약 2절 "서식 본문") — 기본 글꼴 div 로 감싼 편집기 HTML. 없으면 텍스트 메일 */
|
||||
bodyHtml?: string
|
||||
attachments: AttachmentRef[]
|
||||
/** 답장할 메일의 id — 스레드 헤더는 서버가 채운다 */
|
||||
inReplyTo?: string
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isFileDrag, isImageFile, pastedFiles, pastedImageName, renameFile } from './editor-files'
|
||||
|
||||
function clipboard(files: File[], text = '', html = ''): DataTransfer {
|
||||
const values: Record<string, string> = { 'text/plain': text, 'text/html': html }
|
||||
return {
|
||||
files,
|
||||
getData: (type: string) => values[type] ?? '',
|
||||
} as unknown as DataTransfer
|
||||
}
|
||||
|
||||
const png = (name = 'image.png') => new File(['png'], name, { type: 'image/png' })
|
||||
|
||||
describe('pastedFiles — 붙여 넣기에서 첨부로 돌릴 파일', () => {
|
||||
it('글 없이 파일만 있으면 그 파일들', () => {
|
||||
const image = png()
|
||||
expect(pastedFiles(clipboard([image]))).toEqual([image])
|
||||
})
|
||||
|
||||
it('글이 함께 오면(워드·엑셀 복사) 글을 붙여 넣는다 — 첨부로 돌리지 않는다', () => {
|
||||
expect(pastedFiles(clipboard([png()], '표 내용'))).toEqual([])
|
||||
})
|
||||
|
||||
it('워드 · 엑셀처럼 HTML 에 글이 있으면 글을 붙여 넣는다', () => {
|
||||
expect(pastedFiles(clipboard([png()], '표 내용', '<table><tr><td>표 내용</td></tr></table>'))).toEqual([])
|
||||
})
|
||||
|
||||
it('브라우저의 "이미지 복사" — HTML 이 그림(<img>)뿐이거나 글이 그림 주소뿐이면 글이 없는 것으로 보고 그림을 첨부로', () => {
|
||||
const image = png()
|
||||
const address = 'https://img.example/photo.png'
|
||||
|
||||
expect(pastedFiles(clipboard([image], '', `<html><body><!--StartFragment--><img src="${address}"><!--EndFragment--></body></html>`))).toEqual([image])
|
||||
expect(pastedFiles(clipboard([image], address))).toEqual([image])
|
||||
expect(pastedFiles(clipboard([image], address, `<img src="${address}" alt="사진">`))).toEqual([image])
|
||||
expect(pastedFiles(clipboard([image], 'data:image/png;base64,iVBORw0KGgo='))).toEqual([image])
|
||||
})
|
||||
|
||||
it('그림 주소 말고 다른 글이 함께 있으면 글을 붙여 넣는다', () => {
|
||||
expect(pastedFiles(clipboard([png()], 'https://img.example/a.png 사진 설명'))).toEqual([])
|
||||
expect(pastedFiles(clipboard([png()], '', '<p>사진 설명</p><img src="https://img.example/a.png">'))).toEqual([])
|
||||
})
|
||||
|
||||
it('파일이 없거나 클립보드가 없으면 빈 목록', () => {
|
||||
expect(pastedFiles(clipboard([]))).toEqual([])
|
||||
expect(pastedFiles(null)).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pastedImageName — "붙여넣은 이미지 N.확장자"', () => {
|
||||
it('브라우저가 붙인 이름(image.png)이나 이름 없는 그림이면 바꾼다 — 확장자는 형식에서', () => {
|
||||
expect(pastedImageName(png(), 1)).toBe('붙여넣은 이미지 1.png')
|
||||
expect(pastedImageName(new File(['j'], '', { type: 'image/jpeg' }), 2)).toBe('붙여넣은 이미지 2.jpg')
|
||||
expect(pastedImageName(new File(['w'], 'IMAGE.WEBP', { type: '' }), 3)).toBeNull()
|
||||
})
|
||||
|
||||
it('원래 이름이 있는 파일 · 그림이 아닌 파일은 그대로 (null)', () => {
|
||||
expect(pastedImageName(png('화면 캡처.png'), 1)).toBeNull()
|
||||
expect(pastedImageName(new File(['t'], 'image.txt', { type: 'text/plain' }), 1)).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('renameFile · isImageFile · isFileDrag', () => {
|
||||
it('이름만 바꾸고 형식은 그대로', () => {
|
||||
const renamed = renameFile(png(), '붙여넣은 이미지 1.png')
|
||||
|
||||
expect(renamed.name).toBe('붙여넣은 이미지 1.png')
|
||||
expect(renamed.type).toBe('image/png')
|
||||
expect(isImageFile(renamed)).toBe(true)
|
||||
expect(isImageFile(new File(['p'], 'a.pdf', { type: 'application/pdf' }))).toBe(false)
|
||||
})
|
||||
|
||||
it('파일을 끌 때만 덮개를 보인다 — 본문 글을 옮기는 끌기는 아니다', () => {
|
||||
const drag = (types: string[]) => ({ dataTransfer: { types } }) as unknown as DragEvent
|
||||
|
||||
expect(isFileDrag(drag(['Files']))).toBe(true)
|
||||
expect(isFileDrag(drag(['text/html', 'text/plain']))).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,69 @@
|
||||
// 본문에 붙여 넣거나 끌어다 놓은 파일 → 첨부 (디자인 README "본문 이미지 → 첨부 파일", 사용자 확정 2026-10-27)
|
||||
// 본문에는 이미지를 넣지 않는다. 쓰기 화면(ComposePage)과 편집기가 함께 쓰므로 편집기(TipTap)에 기대지 않는다.
|
||||
|
||||
import { PASTED_IMAGE_NAME_PREFIX } from '@/shared/constants/mail-editor'
|
||||
|
||||
/** 브라우저가 클립보드 그림에 붙이는 이름 — 이런 이름이면 "붙여넣은 이미지 N" 으로 바꾼다 */
|
||||
const GENERIC_IMAGE_NAME = /^image\.[a-z0-9]+$/i
|
||||
|
||||
/** 클립보드 그림 형식 → 확장자 (첨부 허용 형식 안의 그림만 — 계약 2절 "첨부 업로드 한도") */
|
||||
const IMAGE_EXTENSIONS: Readonly<Record<string, string>> = {
|
||||
'image/png': 'png',
|
||||
'image/jpeg': 'jpg',
|
||||
'image/gif': 'gif',
|
||||
'image/webp': 'webp',
|
||||
}
|
||||
|
||||
const DEFAULT_IMAGE_EXTENSION = 'png'
|
||||
|
||||
export function isImageFile(file: File): boolean {
|
||||
return file.type.startsWith('image/')
|
||||
}
|
||||
|
||||
/** 끌고 있는 것이 파일인가 — 본문 안에서 글을 옮기는 끌기에는 덮개를 보이지 않는다 */
|
||||
export function isFileDrag(event: DragEvent): boolean {
|
||||
return Array.from(event.dataTransfer?.types ?? []).includes('Files')
|
||||
}
|
||||
|
||||
/** 글 한 덩어리가 그림 주소뿐인가 — 브라우저의 "이미지 복사"가 그림과 함께 주소를 글로 넣는다 */
|
||||
const IMAGE_ADDRESS_ONLY = /^(?:https?:\/\/|data:image\/|blob:|file:)\S*$/i
|
||||
|
||||
/**
|
||||
* 클립보드에 붙여 넣을 글이 있는가.
|
||||
* - HTML 이 있으면 그 글자로 본다 — 그림(<img>)뿐이면 글이 없다 (떨어진 문서에서 읽는다 — 그림을 내려받거나 처리기를 돌리지 않는다)
|
||||
* - HTML 이 없으면 글(text/plain)로 본다 — 그림 주소 하나뿐이면 글이 없다
|
||||
*/
|
||||
function hasPastableText(data: DataTransfer): boolean {
|
||||
const html = data.getData('text/html')
|
||||
if (html !== '') {
|
||||
const text = new DOMParser().parseFromString(html, 'text/html').body.textContent ?? ''
|
||||
return text.trim() !== ''
|
||||
}
|
||||
const text = data.getData('text/plain').trim()
|
||||
return text !== '' && !IMAGE_ADDRESS_ONLY.test(text)
|
||||
}
|
||||
|
||||
/**
|
||||
* 붙여 넣기에서 첨부로 돌릴 파일 — 클립보드에 글 없이 파일만 있을 때.
|
||||
* 워드·엑셀에서 복사하면 글과 함께 고른 부분의 그림도 오는데, 그때는 글을 붙여 넣는다(그림은 편집기가 받지 않는다).
|
||||
* 브라우저에서 "이미지 복사"를 하면 그림과 함께 <img> 뿐인 HTML 이나 그림 주소가 오는데, 붙여 넣을 글이 없으니 그림을 첨부로 돌린다.
|
||||
*/
|
||||
export function pastedFiles(data: DataTransfer | null): File[] {
|
||||
if (!data || data.files.length === 0) return []
|
||||
if (hasPastableText(data)) return []
|
||||
return Array.from(data.files)
|
||||
}
|
||||
|
||||
/** 클립보드 그림의 첨부 이름 — "붙여넣은 이미지 1.png". 원래 이름이 있는 파일이면 null(그대로 쓴다) */
|
||||
export function pastedImageName(file: File, index: number): string | null {
|
||||
if (!isImageFile(file)) return null
|
||||
if (file.name !== '' && !GENERIC_IMAGE_NAME.test(file.name)) return null
|
||||
const fromName = GENERIC_IMAGE_NAME.test(file.name) ? file.name.split('.').pop() : undefined
|
||||
const extension = IMAGE_EXTENSIONS[file.type] ?? fromName ?? DEFAULT_IMAGE_EXTENSION
|
||||
return `${PASTED_IMAGE_NAME_PREFIX} ${index}.${extension.toLowerCase()}`
|
||||
}
|
||||
|
||||
/** 이름만 바꾼 같은 파일 */
|
||||
export function renameFile(file: File, name: string): File {
|
||||
return new File([file], name, { type: file.type, lastModified: file.lastModified })
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { collapsedToolbarGroups, type ToolbarMeasure } from './editor-toolbar-layout'
|
||||
|
||||
/**
|
||||
* 디자인 원본의 묶음 폭(뒤 구분선 포함) — 실행 취소 93 · 글꼴 177 · 굵게 묶음 135 · 색 93 · 목록 93 · 링크 51, 더보기 40, 간격 2.
|
||||
* 툴바 좌우 여백 20 씩을 뺀 폭이 available 이다.
|
||||
*/
|
||||
const DESIGN: Omit<ToolbarMeasure, 'available'> = {
|
||||
groups: { history: 93, font: 177, marks: 135, color: 93, list: 93, link: 51 },
|
||||
more: 40,
|
||||
gap: 2,
|
||||
}
|
||||
const TOOLBAR_INLINE_PADDING = 40
|
||||
|
||||
function hiddenAt(toolbarWidth: number): string[] {
|
||||
return [...collapsedToolbarGroups({ ...DESIGN, available: toolbarWidth - TOOLBAR_INLINE_PADDING })]
|
||||
}
|
||||
|
||||
describe('collapsedToolbarGroups — 좁은 폭에서 묶음째 더보기로 (디자인 README "좁은 폭")', () => {
|
||||
it('734px 이상이면 모두 보인다', () => {
|
||||
expect(hiddenAt(800)).toEqual([])
|
||||
expect(hiddenAt(734)).toEqual([])
|
||||
})
|
||||
|
||||
it.each([
|
||||
[733, ['history']],
|
||||
[639, ['history']],
|
||||
[638, ['history', 'font']],
|
||||
[460, ['history', 'font']],
|
||||
[459, ['history', 'font', 'color']],
|
||||
[365, ['history', 'font', 'color']],
|
||||
[364, ['history', 'font', 'color', 'link']],
|
||||
[312, ['history', 'font', 'color', 'link']],
|
||||
[311, ['history', 'font', 'color', 'link', 'list']],
|
||||
])('%ipx — 숨는 묶음 %j (실행 취소 → 글꼴 → 색 → 링크 → 목록)', (width, expected) => {
|
||||
expect(hiddenAt(width)).toEqual(expected)
|
||||
})
|
||||
|
||||
it('굵게 · 기울임 · 밑줄과 더보기는 늘 남는다 — 아주 좁아도', () => {
|
||||
expect(hiddenAt(120)).not.toContain('marks')
|
||||
expect(hiddenAt(120)).toHaveLength(5)
|
||||
})
|
||||
|
||||
it('고정 px 이 아니라 잰 폭으로 정한다 — 글자 크기 설정으로 단추가 커지면 더 일찍 접힌다', () => {
|
||||
const larger = {
|
||||
groups: { history: 116, font: 221, marks: 169, color: 116, list: 116, link: 64 },
|
||||
more: 50,
|
||||
gap: 2.5,
|
||||
available: 700,
|
||||
}
|
||||
|
||||
expect([...collapsedToolbarGroups(larger)]).toEqual(['history', 'font'])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,41 @@
|
||||
// 편집기 툴바 좁은 폭 처리 — 순수 함수 (디자인 README "메일 쓰기 · 좁은 폭")
|
||||
// 툴바는 줄을 바꾸지 않는다. 폭이 모자라면 정해진 순서로 묶음째 더보기에 옮긴다.
|
||||
// 폭은 고정 px 이 아니라 화면에서 잰 값을 넘긴다 — 글자 크기 설정이 달라도 같은 순서로 접힌다.
|
||||
|
||||
/** 툴바 묶음 — 툴바 순서 */
|
||||
export type ToolbarGroup = 'history' | 'font' | 'marks' | 'color' | 'list' | 'link'
|
||||
|
||||
export const TOOLBAR_GROUPS: readonly ToolbarGroup[] = ['history', 'font', 'marks', 'color', 'list', 'link']
|
||||
|
||||
/**
|
||||
* 더보기로 옮기는 순서 — 실행 취소 · 다시 실행 → 글꼴 · 크기 → 색 → 링크 → 목록.
|
||||
* 굵게 · 기울임 · 밑줄(marks)과 더보기 단추는 늘 남는다.
|
||||
*/
|
||||
export const TOOLBAR_COLLAPSE_ORDER: readonly ToolbarGroup[] = ['history', 'font', 'color', 'link', 'list']
|
||||
|
||||
export interface ToolbarMeasure {
|
||||
/** 툴바 안쪽에서 버튼이 쓸 수 있는 폭 (좌우 여백 뺀 값) */
|
||||
available: number
|
||||
/** 묶음마다 잰 폭 (뒤의 구분선 포함) */
|
||||
groups: Readonly<Record<ToolbarGroup, number>>
|
||||
/** 더보기 단추 폭 */
|
||||
more: number
|
||||
/** 툴바 칸 사이 간격 */
|
||||
gap: number
|
||||
}
|
||||
|
||||
/** 폭이 모자라 더보기로 옮길 묶음 — 정해진 순서로 하나씩 옮기며 들어갈 때까지 */
|
||||
export function collapsedToolbarGroups(measure: ToolbarMeasure): Set<ToolbarGroup> {
|
||||
const hidden = new Set<ToolbarGroup>()
|
||||
const required = () => {
|
||||
const visible = TOOLBAR_GROUPS.filter((group) => !hidden.has(group))
|
||||
const itemCount = visible.length + 1
|
||||
const widths = visible.reduce((sum, group) => sum + measure.groups[group], 0)
|
||||
return widths + measure.more + measure.gap * (itemCount - 1)
|
||||
}
|
||||
for (const group of TOOLBAR_COLLAPSE_ORDER) {
|
||||
if (required() <= measure.available) break
|
||||
hidden.add(group)
|
||||
}
|
||||
return hidden
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { buildEditorShortcuts } from '@/shared/constants/mail-editor'
|
||||
import { formatShortcut, isApplePlatform } from './keyboard-shortcut'
|
||||
|
||||
describe('isApplePlatform — TipTap(prosemirror-keymap)의 Mod 판정과 같다', () => {
|
||||
it.each(['MacIntel', 'iPhone', 'iPad', 'iPod'])('맥 · 아이폰 · 아이패드: %s', (platform) => {
|
||||
expect(isApplePlatform(platform)).toBe(true)
|
||||
})
|
||||
|
||||
it.each(['Win32', 'Linux x86_64', ''])('그 밖: "%s"', (platform) => {
|
||||
expect(isApplePlatform(platform)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('formatShortcut — 표기와 aria-keyshortcuts', () => {
|
||||
it('윈도우 · 리눅스는 Ctrl', () => {
|
||||
expect(formatShortcut(['Mod', 'Shift', 'S'], false)).toEqual({ label: 'Ctrl+Shift+S', aria: 'Control+Shift+S' })
|
||||
expect(formatShortcut(['Mod', '['], false)).toEqual({ label: 'Ctrl+[', aria: 'Control+[' })
|
||||
})
|
||||
|
||||
it('맥은 ⌘ — 수정 글쇠는 애플 순서(⌥ ⇧ ⌘)로 앞에, aria 는 Meta', () => {
|
||||
expect(formatShortcut(['Mod', 'Shift', 'S'], true)).toEqual({ label: '⇧⌘S', aria: 'Meta+Shift+S' })
|
||||
expect(formatShortcut(['Mod', 'B'], true)).toEqual({ label: '⌘B', aria: 'Meta+B' })
|
||||
expect(formatShortcut(['Mod', '\\'], true)).toEqual({ label: '⌘\\', aria: 'Meta+\\' })
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildEditorShortcuts — 편집기 단축키 (디자인 README "단축키")', () => {
|
||||
it('윈도우는 디자인 표기 그대로', () => {
|
||||
const shortcuts = buildEditorShortcuts(false)
|
||||
|
||||
expect(shortcuts.bold).toEqual({ label: 'Ctrl+B', aria: 'Control+B' })
|
||||
expect(shortcuts.redo).toEqual({ label: 'Ctrl+Y', aria: 'Control+Y' })
|
||||
expect(shortcuts.outdent.label).toBe('Ctrl+[')
|
||||
})
|
||||
|
||||
it('맥은 ⌘ — 다시 실행은 맥의 관례대로 ⇧⌘Z (편집기는 ⌘Y 도 받는다)', () => {
|
||||
const shortcuts = buildEditorShortcuts(true)
|
||||
|
||||
expect(shortcuts.bold).toEqual({ label: '⌘B', aria: 'Meta+B' })
|
||||
expect(shortcuts.redo).toEqual({ label: '⇧⌘Z', aria: 'Meta+Shift+Z' })
|
||||
expect(shortcuts.indent.label).toBe('⌘]')
|
||||
expect(Object.values(shortcuts).every((shortcut) => !shortcut.label.includes('Ctrl'))).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,47 @@
|
||||
// 단축키 표기 — 순수 함수 (디자인 README "본문 서식 편집기 · 단축키")
|
||||
// TipTap 의 'Mod' 는 맥 · 아이폰 · 아이패드에서 ⌘(Command), 그 밖에서는 Ctrl 이다.
|
||||
// 판정은 TipTap 이 쓰는 prosemirror-keymap 과 같은 기준(navigator.platform)으로 한다 — 보이는 표기와 실제로 먹는 키가 어긋나지 않게.
|
||||
|
||||
/** 단축키 한 개 — 화면에 보이는 글자와 aria-keyshortcuts 값 */
|
||||
export interface ShortcutText {
|
||||
label: string
|
||||
aria: string
|
||||
}
|
||||
|
||||
/** 키 이름 — 'Mod' · 'Shift' · 'Alt' 와 마지막 글쇠 ('B' · '[' · '\\' 등) */
|
||||
export type ShortcutKeys = readonly string[]
|
||||
|
||||
/** prosemirror-keymap 의 맥 판정과 같다 */
|
||||
export function isApplePlatform(platform: string): boolean {
|
||||
return /Mac|iP(hone|[oa]d)/.test(platform)
|
||||
}
|
||||
|
||||
/** 지금 브라우저의 플랫폼 이름 — 브라우저가 아니면 빈 문자열 */
|
||||
export function currentPlatform(): string {
|
||||
return typeof navigator === 'undefined' ? '' : navigator.platform
|
||||
}
|
||||
|
||||
/** 맥 표기의 수정 글쇠 — 애플 순서(⌃ ⌥ ⇧ ⌘)로 앞에 붙인다 */
|
||||
const APPLE_MODIFIERS: readonly (readonly [key: string, symbol: string])[] = [
|
||||
['Alt', '⌥'],
|
||||
['Shift', '⇧'],
|
||||
['Mod', '⌘'],
|
||||
]
|
||||
|
||||
/** 그 밖의 플랫폼 표기 · aria 이름 */
|
||||
const LABEL_NAMES: Readonly<Record<string, string>> = { Mod: 'Ctrl' }
|
||||
const ARIA_NAMES: Readonly<Record<string, string>> = { Mod: 'Control' }
|
||||
const APPLE_ARIA_NAMES: Readonly<Record<string, string>> = { Mod: 'Meta' }
|
||||
|
||||
/**
|
||||
* 키 이름 → 표기 · aria-keyshortcuts.
|
||||
* - 윈도우 · 리눅스: "Ctrl+Shift+S" · "Control+Shift+S"
|
||||
* - 맥: "⇧⌘S" · "Meta+Shift+S"
|
||||
*/
|
||||
export function formatShortcut(keys: ShortcutKeys, apple: boolean): ShortcutText {
|
||||
const aria = keys.map((key) => (apple ? APPLE_ARIA_NAMES : ARIA_NAMES)[key] ?? key).join('+')
|
||||
if (!apple) return { label: keys.map((key) => LABEL_NAMES[key] ?? key).join('+'), aria }
|
||||
const modifiers = APPLE_MODIFIERS.filter(([key]) => keys.includes(key)).map(([, symbol]) => symbol)
|
||||
const rest = keys.filter((key) => !APPLE_MODIFIERS.some(([modifier]) => modifier === key))
|
||||
return { label: [...modifiers, ...rest].join(''), aria }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { isAllowedLinkHref, normalizeLinkHref, shouldAutoLinkText } from './link-href'
|
||||
|
||||
describe('isAllowedLinkHref — 링크는 http · https · mailto 만 (계약 2절 "서식 본문")', () => {
|
||||
it.each(['https://wiki.arasystem.it.kr/a', 'http://example.kr', 'mailto:ara.kim@arasystem.it.kr', 'HTTPS://X.KR'])(
|
||||
'받는다: %s',
|
||||
(href) => {
|
||||
expect(isAllowedLinkHref(href)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each([
|
||||
'javascript:alert(1)',
|
||||
'JaVaScRiPt:alert(1)',
|
||||
'data:text/html,<script>alert(1)</script>',
|
||||
'vbscript:msgbox(1)',
|
||||
'file:///etc/passwd',
|
||||
'ftp://example.kr',
|
||||
'//example.kr',
|
||||
'/mail/inbox',
|
||||
'https://',
|
||||
'https://exa mple.kr',
|
||||
'https://example.kr\u0000',
|
||||
' https://example.kr',
|
||||
])('받지 않는다: %s', (href) => {
|
||||
expect(isAllowedLinkHref(href)).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('normalizeLinkHref — 링크 칸에 쓴 주소', () => {
|
||||
it('프로토콜이 없으면 붙인다 — 메일 주소는 mailto:, 그 밖은 https://', () => {
|
||||
expect(normalizeLinkHref('wiki.arasystem.it.kr/a')).toBe('https://wiki.arasystem.it.kr/a')
|
||||
expect(normalizeLinkHref(' ara.kim@arasystem.it.kr ')).toBe('mailto:ara.kim@arasystem.it.kr')
|
||||
expect(normalizeLinkHref('https://example.kr')).toBe('https://example.kr')
|
||||
})
|
||||
|
||||
it('쓸 수 없는 주소는 null — javascript: 를 https:// 로 감싸 통과시키지 않는다', () => {
|
||||
expect(normalizeLinkHref('javascript:alert(1)')).toBeNull()
|
||||
expect(normalizeLinkHref('')).toBeNull()
|
||||
expect(normalizeLinkHref(' ')).toBeNull()
|
||||
expect(normalizeLinkHref('exa mple')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('shouldAutoLinkText — 쓰는 중에 저절로 링크로 바꿀 글', () => {
|
||||
it.each(['www.naver.com', 'naver.com', 'ara.kim@arasystem.it.kr', 'https://wiki.arasystem.it.kr/a', 'mailto:ara@x.kr'])(
|
||||
'바꾼다(프로토콜이 없어도): %s',
|
||||
(text) => {
|
||||
expect(shouldAutoLinkText(text)).toBe(true)
|
||||
},
|
||||
)
|
||||
|
||||
it.each(['1.2.3.4', '10.0.0.1/admin', 'javascript:alert(1)', 'ftp://example.kr', 'exa mple.kr', ''])(
|
||||
'바꾸지 않는다 — 프로토콜 없는 IP(버전 번호 등) · 허용 밖 프로토콜: %s',
|
||||
(text) => {
|
||||
expect(shouldAutoLinkText(text)).toBe(false)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
// 본문 링크 주소 검사 — 순수 함수 (계약 2절 "서식 본문": 링크는 http · https · mailto 만)
|
||||
// 서버 정화(sanitize-outgoing-html.ts)도 같은 규칙으로 그 밖의 링크를 글자만 남기지만,
|
||||
// 편집기에서부터 막아 javascript: 같은 주소가 본문에 들어가지 않게 한다.
|
||||
|
||||
/** 허용하는 주소의 시작 — 서버의 SAFE_LINK 와 같다 */
|
||||
const SAFE_LINK = /^(?:https?:\/\/|mailto:)/i
|
||||
const ALLOWED_PROTOCOLS: readonly string[] = ['http:', 'https:', 'mailto:']
|
||||
/** 공백·제어 문자 — 주소 안에 있으면 받지 않는다 (숨은 문자로 검사를 비껴가지 못하게) */
|
||||
const UNSAFE_CHARACTERS = /[\s\p{Cc}]/u
|
||||
/** 프로토콜이 붙어 있는가 (예: https:, mailto:, javascript:) */
|
||||
const HAS_SCHEME = /^[a-z][a-z0-9+.-]*:/i
|
||||
/** 메일 주소처럼 보이는가 — 그러면 mailto: 를 붙인다 */
|
||||
const EMAIL_LIKE = /^[^\s@/]+@[^\s@/]+\.[^\s@/]+$/
|
||||
|
||||
/** 링크로 둘 수 있는 주소인가 */
|
||||
export function isAllowedLinkHref(href: string): boolean {
|
||||
if (!SAFE_LINK.test(href) || UNSAFE_CHARACTERS.test(href)) return false
|
||||
try {
|
||||
const url = new URL(href)
|
||||
if (!ALLOWED_PROTOCOLS.includes(url.protocol)) return false
|
||||
return url.protocol === 'mailto:' || url.hostname !== ''
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용자가 링크 칸에 쓴 주소를 링크 주소로 바꾼다. 쓸 수 없으면 null.
|
||||
* 프로토콜이 없으면 붙여 본다 — 메일 주소면 mailto:, 그 밖은 https://
|
||||
*/
|
||||
export function normalizeLinkHref(input: string): string | null {
|
||||
const value = input.trim()
|
||||
if (value === '') return null
|
||||
let candidate = value
|
||||
if (!HAS_SCHEME.test(value)) {
|
||||
candidate = EMAIL_LIKE.test(value) ? `mailto:${value}` : `https://${value}`
|
||||
}
|
||||
return isAllowedLinkHref(candidate) ? candidate : null
|
||||
}
|
||||
|
||||
/** 프로토콜 없이 쓴 IP 주소 — 버전 번호(1.2.3.4) 같은 글이라 저절로 링크로 바꾸지 않는다 (TipTap 기본 규칙과 같다) */
|
||||
const BARE_IPV4 = /^\d{1,3}(?:\.\d{1,3}){3}(?:[/:?#]|$)/
|
||||
|
||||
/**
|
||||
* 쓰는 중에 저절로 링크로 바꿀 글인가 — 링크 칸과 같은 규칙으로 프로토콜을 붙여 본다.
|
||||
* 그래서 www.naver.com · naver.com · 메일 주소도 링크가 된다 (실제 주소는 편집기가 https:// · mailto: 를 붙인다).
|
||||
*/
|
||||
export function shouldAutoLinkText(text: string): boolean {
|
||||
if (!HAS_SCHEME.test(text) && BARE_IPV4.test(text)) return false
|
||||
return normalizeLinkHref(text) !== null
|
||||
}
|
||||
@@ -1,11 +1,7 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import { fitDraftBody, fitDraftSubject, truncateText } from './mail-draft'
|
||||
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import { fitDraftSubject, truncateText } from './mail-draft'
|
||||
|
||||
describe('truncateText', () => {
|
||||
it('한도 안이면 그대로, 넘으면 앞에서부터 한도까지 남긴다', () => {
|
||||
@@ -32,38 +28,3 @@ describe('fitDraftSubject — 제목 255자 (계약 2절)', () => {
|
||||
expect(fitDraftSubject(over).startsWith('Re: ')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('fitDraftBody — 본문 100,000자 (계약 2절)', () => {
|
||||
const head = '\n\n──────────\n2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@arasystem.it.kr> 작성:\n'
|
||||
|
||||
it('100,000자면 그대로 둔다', () => {
|
||||
const exact = head + 'x'.repeat(MAIL_BODY_MAX_LENGTH - head.length)
|
||||
|
||||
expect(fitDraftBody(exact)).toBe(exact)
|
||||
})
|
||||
|
||||
it('100,001자면 뒤쪽 원문을 줄이고 끝에 생략 표시를 둔다 — 앞쪽(쓸 자리·구분선·원문 머리)은 그대로', () => {
|
||||
const over = head + 'x'.repeat(MAIL_BODY_MAX_LENGTH - head.length + 1)
|
||||
|
||||
const fitted = fitDraftBody(over)
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(fitted.startsWith(head)).toBe(true)
|
||||
expect(fitted.endsWith(`\n${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('답장은 생략 표시도 인용 줄 머리("> ")를 붙여 인용 안에 둔다', () => {
|
||||
const over = head + '> 원문 줄\n'.repeat(20_000)
|
||||
|
||||
const fitted = fitDraftBody(over, '> ')
|
||||
|
||||
expect(fitted.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(fitted.endsWith(`\n> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
|
||||
it('아주 긴 원문(1MB 넘게)도 한도 안으로 줄인다 — 앞단 프록시의 413 으로 가지 않게', () => {
|
||||
const huge = head + '가'.repeat(1_100_000)
|
||||
|
||||
expect(fitDraftBody(huge).length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,13 +1,9 @@
|
||||
// 답장·전달 초안을 계약 한도에 맞춘다 — 순수 함수만 둔다 (계약 2절 "답장·전달 초안의 길이")
|
||||
// 서버는 제목 255자 · 본문 100,000자를 넘으면 VAL_001 로 거절한다. 본문이 1MB 를 넘으면 앞단 프록시가
|
||||
// 413(FILE_002)으로 끊어 첨부 문제처럼 보이므로, 초안을 만들 때 미리 맞춘다.
|
||||
// 답장·전달 초안의 제목을 계약 한도에 맞춘다 — 순수 함수만 둔다 (계약 2절 "답장·전달 초안의 길이")
|
||||
// 서버는 제목 255자를 넘으면 VAL_001 로 거절한다. 본문(텍스트 100,000 · 서식 500,000)은 서식 편집기가 맞춘다
|
||||
// (components/mail/editor/fit-draft.ts).
|
||||
// 길이는 JS 문자열 길이(UTF-16 단위)로 센다 — 서버(글자 수)보다 같거나 길게 세므로 이 한도 안이면 서버도 통과한다.
|
||||
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
|
||||
/** UTF-16 서로게이트 쌍의 앞쪽 범위 — 여기서 자르면 이모지 등이 깨진다 */
|
||||
const HIGH_SURROGATE_START = 0xd800
|
||||
@@ -26,13 +22,3 @@ export function truncateText(text: string, maxLength: number): string {
|
||||
export function fitDraftSubject(subject: string): string {
|
||||
return truncateText(subject, MAIL_SUBJECT_MAX_LENGTH)
|
||||
}
|
||||
|
||||
/**
|
||||
* 본문 — 100,000자를 넘으면 뒤쪽(인용한 원문)을 줄이고 끝에 생략 표시를 둔다. 앞쪽(내가 쓸 자리 · 구분선 · 원문 머리)은 남는다.
|
||||
* linePrefix: 답장은 인용 줄 머리("> ")를 붙여 생략 표시도 인용 안에 둔다.
|
||||
*/
|
||||
export function fitDraftBody(body: string, linePrefix = ''): string {
|
||||
if (body.length <= MAIL_BODY_MAX_LENGTH) return body
|
||||
const mark = `\n${linePrefix}${MAIL_QUOTE_OMITTED_MARK}`
|
||||
return `${truncateText(body, MAIL_BODY_MAX_LENGTH - mark.length).trimEnd()}${mark}`
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { buildForwardBody, buildForwardDraft, buildForwardSubject } from './mail-forward'
|
||||
import { buildForwardDraft, buildForwardSubject } from './mail-forward'
|
||||
|
||||
const ATTACHMENT = { id: 'att1', name: '메일서버_전환_체크리스트.xlsx', type: 'application/vnd.ms-excel', size: 49152 }
|
||||
|
||||
@@ -52,42 +48,37 @@ describe('buildForwardSubject', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForwardBody', () => {
|
||||
it('구분선 + 원문 머리(보낸 사람·날짜·제목·받는 사람·참조) + 원문 그대로', () => {
|
||||
expect(buildForwardBody(message(), 'Asia/Seoul')).toBe(
|
||||
[
|
||||
'',
|
||||
'',
|
||||
'──────────',
|
||||
'전달된 메일',
|
||||
'보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>',
|
||||
'날짜: 2026. 10. 26. (월) 오전 10:41',
|
||||
'제목: 메일 서버 이전 일정 공유',
|
||||
'받는 사람: 김아라 <ara.kim@arasystem.it.kr>',
|
||||
'참조: 인프라팀 <infra@arasystem.it.kr>',
|
||||
'',
|
||||
'김아라님, 안녕하세요.',
|
||||
'',
|
||||
'금요일 저녁에 전환하겠습니다.',
|
||||
].join('\n'),
|
||||
)
|
||||
})
|
||||
|
||||
it('참조가 없으면 참조 줄을 빼고, 텍스트 본문이 없으면 HTML 을 텍스트로 바꾼다', () => {
|
||||
const body = buildForwardBody(message({ cc: [], textBody: null, htmlBody: '<p>첫 줄<br>둘째 줄</p>' }), 'Asia/Seoul')
|
||||
|
||||
expect(body).not.toContain('참조:')
|
||||
expect(body.endsWith('\n\n첫 줄\n둘째 줄')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildForwardDraft', () => {
|
||||
it('원본 첨부를 그대로 넘긴다 (발송 때 id 를 다시 보낸다)', () => {
|
||||
const draft = buildForwardDraft(message(), 'Asia/Seoul')
|
||||
|
||||
expect(draft.subject).toBe('Fwd: 메일 서버 이전 일정 공유')
|
||||
expect(draft.attachments).toEqual([ATTACHMENT])
|
||||
expect(draft.body).toContain('전달된 메일')
|
||||
})
|
||||
|
||||
it('본문 — 답장과 같은 "원본 메일" 머리에 받는 사람 · 참조까지 (보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목)', () => {
|
||||
const { html } = buildForwardDraft(message(), 'Asia/Seoul')
|
||||
|
||||
expect(html).toBe(
|
||||
'<p></p><blockquote data-mail-quote="">' +
|
||||
'<p data-quote-header=""><strong>원본 메일</strong></p>' +
|
||||
'<table data-quote-header=""><tbody>' +
|
||||
'<tr><td>보낸 사람</td><td>이도윤 <doyun.lee@arasystem.it.kr></td></tr>' +
|
||||
'<tr><td>날짜</td><td>2026. 10. 26. (월) 오전 10:41</td></tr>' +
|
||||
'<tr><td>받는 사람</td><td>김아라 <ara.kim@arasystem.it.kr></td></tr>' +
|
||||
'<tr><td>참조</td><td>인프라팀 <infra@arasystem.it.kr></td></tr>' +
|
||||
'<tr><td>제목</td><td>메일 서버 이전 일정 공유</td></tr>' +
|
||||
'</tbody></table>' +
|
||||
'<p>김아라님, 안녕하세요.</p><p></p><p>금요일 저녁에 전환하겠습니다.</p>' +
|
||||
'</blockquote>',
|
||||
)
|
||||
})
|
||||
|
||||
it('참조가 없으면 참조 줄을 뺀다', () => {
|
||||
const { html } = buildForwardDraft(message({ cc: [] }), 'Asia/Seoul')
|
||||
|
||||
expect(html).toContain('<td>받는 사람</td>')
|
||||
expect(html).not.toContain('<td>참조</td>')
|
||||
})
|
||||
|
||||
it('계약 한도에 맞춘다 — 원래 제목 250자면 "Fwd: " 를 붙여 255자, 251자면 255자에서 자른다', () => {
|
||||
@@ -97,12 +88,4 @@ describe('buildForwardDraft', () => {
|
||||
expect(fits.subject).toBe(`Fwd: ${'가'.repeat(250)}`)
|
||||
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
|
||||
})
|
||||
|
||||
it('원문이 본문 한도(100,000자)를 넘으면 원문을 줄이고 끝에 생략 표시를 둔다 — 원문 머리는 남는다', () => {
|
||||
const draft = buildForwardDraft(message({ textBody: '가'.repeat(150_000) }))
|
||||
|
||||
expect(draft.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(draft.body).toContain('보낸 사람: 이도윤 <doyun.lee@arasystem.it.kr>')
|
||||
expect(draft.body.endsWith(`\n${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,16 +1,16 @@
|
||||
// 전달 초안 만들기 — 순수 함수만 둔다 (계약 2절 "전달")
|
||||
// 제목 "Fwd:" 와 원문 본문은 클라이언트가 만들고, 원본 첨부는 id 를 그대로 다시 보낸다. inReplyTo 는 보내지 않는다.
|
||||
// 제목 "Fwd:" 와 원문 인용은 클라이언트가 만들고, 원본 첨부는 id 를 그대로 다시 보낸다. inReplyTo 는 보내지 않는다.
|
||||
// 본문은 서식 편집기에 넣을 HTML 이다 — "원본 메일" 머리(보낸 사람·날짜·받는 사람·참조·제목)와 회색 인용 막대 (mail-quote.ts).
|
||||
|
||||
import { MAIL_FORWARD_HEADING, MAIL_FORWARD_PREFIX, MAIL_QUOTE_SEPARATOR } from '@/shared/constants/mail'
|
||||
import { MAIL_FORWARD_PREFIX } from '@/shared/constants/mail'
|
||||
import type { Attachment, MessageDetail } from '@/shared/types/mail'
|
||||
import { formatMailDateTime } from '@/shared/utils/format'
|
||||
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { fitDraftBody, fitDraftSubject } from '@/shared/utils/mail-draft'
|
||||
import { messageBodyText } from '@/shared/utils/mail-html'
|
||||
import { fitDraftSubject } from '@/shared/utils/mail-draft'
|
||||
import { buildQuoteHtml, forwardQuoteRows, originalBodyHtml } from '@/shared/utils/mail-quote'
|
||||
|
||||
export interface ForwardDraft {
|
||||
subject: string
|
||||
body: string
|
||||
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 전달하는 말을 쓴다) + 원문 인용 */
|
||||
html: string
|
||||
/** 원본 메일의 첨부 — 쓰기 화면의 첨부 목록에 미리 올려 두고 하나씩 뺄 수 있다 */
|
||||
attachments: Attachment[]
|
||||
}
|
||||
@@ -24,43 +24,11 @@ export function buildForwardSubject(subject: string): string {
|
||||
return `${MAIL_FORWARD_PREFIX}${trimmed}`.trimEnd()
|
||||
}
|
||||
|
||||
type ForwardSource = Pick<
|
||||
MessageDetail,
|
||||
'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt' | 'textBody' | 'htmlBody'
|
||||
>
|
||||
|
||||
/**
|
||||
* 전달 본문 — 원문 머리(보낸 사람·날짜·제목·받는 사람·참조) 뒤에 원문을 그대로 붙인다.
|
||||
*
|
||||
* (빈 줄 — 여기에 전달하는 말을 쓴다)
|
||||
*
|
||||
* ──────────
|
||||
* 전달된 메일
|
||||
* 보낸 사람: 이도윤 <doyun.lee@…>
|
||||
* 날짜: 2026. 10. 26. (월) 오전 10:41
|
||||
* 제목: 메일 서버 이전 일정 공유
|
||||
* 받는 사람: 김아라 <ara.kim@…>
|
||||
*
|
||||
* 원문 …
|
||||
*/
|
||||
export function buildForwardBody(message: ForwardSource, timeZone?: string): string {
|
||||
const header = [
|
||||
MAIL_FORWARD_HEADING,
|
||||
message.from ? `보낸 사람: ${formatAddress(message.from)}` : null,
|
||||
`날짜: ${formatMailDateTime(message.sentAt ?? message.receivedAt, timeZone)}`,
|
||||
`제목: ${message.subject || '(제목 없음)'}`,
|
||||
message.to.length > 0 ? `받는 사람: ${formatAddressList(message.to)}` : null,
|
||||
message.cc.length > 0 ? `참조: ${formatAddressList(message.cc)}` : null,
|
||||
].filter((line): line is string => line !== null)
|
||||
|
||||
return `\n\n${MAIL_QUOTE_SEPARATOR}\n${header.join('\n')}\n\n${messageBodyText(message)}`
|
||||
}
|
||||
|
||||
/** 전달 초안 — 제목·본문은 계약 한도(255자·100,000자)에 맞춘다. 넘치면 원문을 줄이고 끝에 생략 표시를 둔다 */
|
||||
/** 전달 초안 — 제목은 255자에 맞춘다 */
|
||||
export function buildForwardDraft(message: MessageDetail, timeZone?: string): ForwardDraft {
|
||||
return {
|
||||
subject: fitDraftSubject(buildForwardSubject(message.subject)),
|
||||
body: fitDraftBody(buildForwardBody(message, timeZone)),
|
||||
html: buildQuoteHtml(forwardQuoteRows(message, timeZone), originalBodyHtml(message)),
|
||||
attachments: [...message.attachments],
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, htmlToText, sanitizeMailHtml } from './mail-html'
|
||||
import { MAIL_FRAME_CSP, buildMailDocument, escapeHtml, sanitizeMailHtml } from './mail-html'
|
||||
|
||||
// 보안 핵심 — 서버 정화에 빈틈이 있어도 클라이언트가 한 번 더 걸러 내는지 확인한다.
|
||||
describe('sanitizeMailHtml', () => {
|
||||
@@ -131,15 +131,3 @@ describe('escapeHtml', () => {
|
||||
expect(escapeHtml(`<a href="x">'&'</a>`)).toBe('<a href="x">'&'</a>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('htmlToText (답장 인용용)', () => {
|
||||
it('문단·줄바꿈을 살려 텍스트로 바꾼다', () => {
|
||||
expect(htmlToText('<p>김아라님,</p>\n <p>안녕하세요.<br>확인 부탁드립니다.</p>')).toBe(
|
||||
'김아라님,\n\n안녕하세요.\n확인 부탁드립니다.',
|
||||
)
|
||||
})
|
||||
|
||||
it('스크립트 내용은 텍스트로 새지 않는다', () => {
|
||||
expect(htmlToText('<p>본문</p><script>alert(1)</script>')).toBe('본문')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -154,68 +154,3 @@ export function buildMailDocument(source: MailBodySource): string {
|
||||
'</html>',
|
||||
].join('')
|
||||
}
|
||||
|
||||
const BLOCK_TAGS = new Set([
|
||||
'P',
|
||||
'DIV',
|
||||
'LI',
|
||||
'TR',
|
||||
'H1',
|
||||
'H2',
|
||||
'H3',
|
||||
'H4',
|
||||
'H5',
|
||||
'H6',
|
||||
'BLOCKQUOTE',
|
||||
'PRE',
|
||||
'TABLE',
|
||||
'UL',
|
||||
'OL',
|
||||
'SECTION',
|
||||
'ARTICLE',
|
||||
'HEADER',
|
||||
'FOOTER',
|
||||
])
|
||||
|
||||
/**
|
||||
* 답장 인용용 — HTML 본문을 일반 텍스트로 바꾼다.
|
||||
* 정화한 뒤 DOMParser 로 읽는다 (DOMParser 문서는 스크립트를 실행하지 않고 이미지도 내려받지 않는다).
|
||||
*/
|
||||
export function htmlToText(html: string): string {
|
||||
const doc = new DOMParser().parseFromString(sanitizeMailHtml(html), 'text/html')
|
||||
const chunks: string[] = []
|
||||
|
||||
const walk = (node: Node, preformatted: boolean) => {
|
||||
if (node.nodeType === Node.TEXT_NODE) {
|
||||
const text = node.nodeValue ?? ''
|
||||
chunks.push(preformatted ? text : text.replace(/\s+/g, ' '))
|
||||
return
|
||||
}
|
||||
if (node.nodeType !== Node.ELEMENT_NODE) return
|
||||
|
||||
const element = node as Element
|
||||
if (element.tagName === 'BR') {
|
||||
chunks.push('\n')
|
||||
return
|
||||
}
|
||||
const isBlock = BLOCK_TAGS.has(element.tagName)
|
||||
if (isBlock) chunks.push('\n')
|
||||
const childPreformatted = preformatted || element.tagName === 'PRE'
|
||||
element.childNodes.forEach((child) => walk(child, childPreformatted))
|
||||
if (isBlock) chunks.push('\n')
|
||||
}
|
||||
walk(doc.body, false)
|
||||
|
||||
return chunks
|
||||
.join('')
|
||||
.replace(/[ \t]+\n/g, '\n')
|
||||
.replace(/\n[ \t]+/g, '\n')
|
||||
.replace(/\n{3,}/g, '\n\n')
|
||||
.trim()
|
||||
}
|
||||
|
||||
/** 답장·전달에 넣을 원문 텍스트 — 텍스트 본문이 없으면 HTML 을 텍스트로 바꾼다 */
|
||||
export function messageBodyText(source: MailBodySource): string {
|
||||
if (source.textBody !== null) return source.textBody.replace(/\r\n?/g, '\n')
|
||||
return source.htmlBody ? htmlToText(source.htmlBody) : ''
|
||||
}
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import {
|
||||
QUOTE_SOURCE_MAX_LENGTH,
|
||||
buildQuoteHtml,
|
||||
forwardQuoteRows,
|
||||
originalBodyHtml,
|
||||
replyQuoteRows,
|
||||
textToParagraphHtml,
|
||||
} from './mail-quote'
|
||||
|
||||
/** 잘랐을 때 끝에 두는 생략 표시 문단 */
|
||||
const OMITTED = `<p>${MAIL_QUOTE_OMITTED_MARK}</p>`
|
||||
|
||||
const HEADER: Pick<MessageDetail, 'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt'> = {
|
||||
from: { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' },
|
||||
to: [{ name: '김아라', email: 'ara.kim@arasystem.it.kr' }],
|
||||
cc: [],
|
||||
subject: '',
|
||||
receivedAt: '2026-10-26T01:42:00Z',
|
||||
sentAt: null,
|
||||
}
|
||||
|
||||
describe('textToParagraphHtml', () => {
|
||||
it('한 줄이 한 문단 — 빈 줄은 빈 문단, 특수 문자는 바꾼다', () => {
|
||||
expect(textToParagraphHtml('a <b>\r\n\r\n"c" & d')).toBe('<p>a <b></p><p></p><p>"c" & d</p>')
|
||||
})
|
||||
})
|
||||
|
||||
describe('originalBodyHtml', () => {
|
||||
it('서식 본문이 있으면 그것, 없으면 텍스트를 문단으로', () => {
|
||||
expect(originalBodyHtml({ htmlBody: '<p>서식</p>', textBody: '텍스트' })).toBe('<p>서식</p>')
|
||||
expect(originalBodyHtml({ htmlBody: null, textBody: '한 줄\n두 줄' })).toBe('<p>한 줄</p><p>두 줄</p>')
|
||||
expect(originalBodyHtml({ htmlBody: null, textBody: null })).toBe('<p></p>')
|
||||
})
|
||||
|
||||
it('아주 긴 원문은 편집기로 옮기기 전에 자르고 끝에 생략 표시를 둔다 (정확한 맞춤은 편집기가 한다)', () => {
|
||||
const html = originalBodyHtml({ htmlBody: 'x'.repeat(QUOTE_SOURCE_MAX_LENGTH + 10), textBody: null })
|
||||
|
||||
expect(html).toBe(`${'x'.repeat(QUOTE_SOURCE_MAX_LENGTH)}${OMITTED}`)
|
||||
expect(originalBodyHtml({ htmlBody: null, textBody: 'y'.repeat(QUOTE_SOURCE_MAX_LENGTH + 1) }).endsWith(OMITTED)).toBe(true)
|
||||
// 한도 안이면 표시를 두지 않는다
|
||||
expect(originalBodyHtml({ htmlBody: '<p>짧은 원문</p>', textBody: null })).not.toContain(MAIL_QUOTE_OMITTED_MARK)
|
||||
})
|
||||
|
||||
it('태그 한가운데서 자르지 않는다 — 닫히지 않은 태그는 그 앞에서 자른다', () => {
|
||||
const head = 'x'.repeat(QUOTE_SOURCE_MAX_LENGTH - 5)
|
||||
const html = originalBodyHtml({ htmlBody: `${head}<span style="color: #B42318">뒤</span>`, textBody: null })
|
||||
|
||||
expect(html).toBe(`${head}${OMITTED}`)
|
||||
})
|
||||
|
||||
it('그림(서버가 넣은 data: 그림 포함)은 자르기 전에 걷어 낸다 — 큰 그림 뒤의 글이 생략 표시 없이 사라지지 않게', () => {
|
||||
const picture = `<img src="data:image/png;base64,${'A'.repeat(QUOTE_SOURCE_MAX_LENGTH + 100_000)}" alt="서명">`
|
||||
const html = originalBodyHtml({ htmlBody: `<p>앞 글</p><p>${picture}</p><p>뒤쪽 본문 내용</p>`, textBody: null })
|
||||
|
||||
expect(html).toBe('<p>앞 글</p><p></p><p>뒤쪽 본문 내용</p>')
|
||||
expect(html).not.toContain('data:')
|
||||
})
|
||||
})
|
||||
|
||||
describe('인용 머리', () => {
|
||||
it('답장 — 보낸 사람 · 날짜 · 제목 (제목이 없으면 "(제목 없음)")', () => {
|
||||
expect(replyQuoteRows(HEADER, 'Asia/Seoul')).toEqual([
|
||||
{ label: '보낸 사람', value: '이도윤 <doyun.lee@arasystem.it.kr>' },
|
||||
{ label: '날짜', value: '2026. 10. 26. (월) 오전 10:42' },
|
||||
{ label: '제목', value: '(제목 없음)' },
|
||||
])
|
||||
})
|
||||
|
||||
it('전달 — 받는 사람 · 참조까지 (비었으면 뺀다), 보낸 날짜가 있으면 그것', () => {
|
||||
const rows = forwardQuoteRows(
|
||||
{ ...HEADER, subject: '일정', sentAt: '2026-10-26T01:41:00Z', cc: [{ name: '', email: 'infra@arasystem.it.kr' }] },
|
||||
'Asia/Seoul',
|
||||
)
|
||||
|
||||
expect(rows.map((row) => row.label)).toEqual(['보낸 사람', '날짜', '받는 사람', '참조', '제목'])
|
||||
expect(rows[1]?.value).toBe('2026. 10. 26. (월) 오전 10:41')
|
||||
expect(forwardQuoteRows({ ...HEADER, to: [] }).map((row) => row.label)).toEqual(['보낸 사람', '날짜', '제목'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildQuoteHtml', () => {
|
||||
it('내가 쓸 빈 문단 + "원본 메일" + 항목 표 + 원문 — 머리 값은 이스케이프한다', () => {
|
||||
expect(buildQuoteHtml([{ label: '제목', value: '<script>' }], '<p>원문</p>')).toBe(
|
||||
'<p></p><blockquote data-mail-quote="">' +
|
||||
'<p data-quote-header=""><strong>원본 메일</strong></p>' +
|
||||
'<table data-quote-header=""><tbody><tr><td>제목</td><td><script></td></tr></tbody></table>' +
|
||||
'<p>원문</p></blockquote>',
|
||||
)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,115 @@
|
||||
// 답장·전달 초안의 원문 인용 HTML — 순수 함수 (계약 2절 "서식 본문", 디자인 README "답장 · 전달의 원문 인용")
|
||||
// 서식 편집기가 이 HTML 을 자기 스키마로 옮긴다 — 편집기가 모르는 요소(이미지 등)는 빠진다.
|
||||
// 한도(텍스트 100,000자 · 서식 500,000자)에 맞추는 일도 편집기가 한다 (components/mail/editor/fit-draft.ts).
|
||||
//
|
||||
// (빈 문단 — 여기에 쓴다)
|
||||
// ┃ 원본 메일 ← data-quote-header 문단 (굵게)
|
||||
// ┃ 보낸 사람 이도윤 <doyun.lee@…> ← data-quote-header 표 (항목 이름 · 값)
|
||||
// ┃ 날짜 2026. 10. 26. (월) 오전 10:42
|
||||
// ┃ 제목 메일 서버 이전 일정 공유
|
||||
// ┃ 원문 …
|
||||
//
|
||||
// data-* 표시는 편집기가 인용 머리를 알아보고 모양을 입히는 데만 쓴다 (서버 정화가 지운다).
|
||||
|
||||
import { MAIL_QUOTE_OMITTED_MARK } from '@/shared/constants/mail'
|
||||
import { MAIL_QUOTE_HEADING } from '@/shared/constants/mail-editor'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import { formatMailDateTime } from '@/shared/utils/format'
|
||||
import { formatAddress, formatAddressList } from '@/shared/utils/mail-address'
|
||||
import { truncateText } from '@/shared/utils/mail-draft'
|
||||
import { escapeHtml } from '@/shared/utils/mail-html'
|
||||
|
||||
export interface QuoteRow {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 원문을 편집기로 옮기기 전에 자르는 길이 — 정확한 맞춤은 편집기가 하고, 이 값은 아주 긴 원문의 파싱 시간만 줄인다.
|
||||
* 서식 본문 한도(500,000자)의 네 배라 맞춤에 쓸 내용이 모자라지 않다. 잘랐으면 끝에 생략 표시를 둔다.
|
||||
*/
|
||||
export const QUOTE_SOURCE_MAX_LENGTH = 2_000_000
|
||||
|
||||
/**
|
||||
* 원문의 그림 태그 — 편집기는 그림을 받지 않는다(본문 이미지는 첨부로). 서버는 본문 안 그림(cid:)을 data: URI 로 넣어 주므로
|
||||
* 그대로 두면 큰 그림 몇 장이 자르는 길이를 다 써서 뒤쪽 글이 생략 표시도 없이 사라진다 — 자르기 전에 걷어 낸다.
|
||||
* htmlBody 는 서버가 정화한 HTML 이라(속성 값의 `>` 는 > 로 바뀌어 있다) 태그 단위로 걷어 낼 수 있다.
|
||||
*/
|
||||
const IMAGE_TAG = /<img\b[^>]*>/gi
|
||||
|
||||
/** 생략 표시 문단 — 편집기의 맞춤(fit-draft)과 같은 글 */
|
||||
const OMITTED_PARAGRAPH = `<p>${escapeHtml(MAIL_QUOTE_OMITTED_MARK)}</p>`
|
||||
|
||||
/** HTML 을 길이에 맞춰 자른다 — 태그 한가운데서 자르지 않고, 잘랐으면 끝에 생략 표시를 둔다 */
|
||||
function truncateHtml(html: string, maxLength: number): string {
|
||||
if (html.length <= maxLength) return html
|
||||
let cut = truncateText(html, maxLength)
|
||||
// 마지막 태그가 닫히지 않았으면 그 태그 앞에서 자른다
|
||||
const lastOpen = cut.lastIndexOf('<')
|
||||
if (lastOpen > cut.lastIndexOf('>')) cut = cut.slice(0, lastOpen)
|
||||
return `${cut}${OMITTED_PARAGRAPH}`
|
||||
}
|
||||
|
||||
const NO_SUBJECT = '(제목 없음)'
|
||||
|
||||
/** 일반 텍스트 → 문단 HTML — 한 줄이 한 문단, 빈 줄은 빈 문단 (Enter 한 번이 한 줄인 편집기와 같다) */
|
||||
export function textToParagraphHtml(text: string): string {
|
||||
return text
|
||||
.replace(/\r\n?/g, '\n')
|
||||
.split('\n')
|
||||
.map((line) => `<p>${escapeHtml(line)}</p>`)
|
||||
.join('')
|
||||
}
|
||||
|
||||
type OriginalSource = Pick<MessageDetail, 'htmlBody' | 'textBody'>
|
||||
|
||||
/** 원문 본문 — 서버가 정화한 htmlBody(그림을 걷어 낸 것), 없으면 textBody 를 문단으로. 아주 길면 자르고 생략 표시 */
|
||||
export function originalBodyHtml(message: OriginalSource): string {
|
||||
if (message.htmlBody) return truncateHtml(message.htmlBody.replace(IMAGE_TAG, ''), QUOTE_SOURCE_MAX_LENGTH)
|
||||
const text = message.textBody ?? ''
|
||||
if (text.length <= QUOTE_SOURCE_MAX_LENGTH) return textToParagraphHtml(text)
|
||||
return `${textToParagraphHtml(truncateText(text, QUOTE_SOURCE_MAX_LENGTH))}${OMITTED_PARAGRAPH}`
|
||||
}
|
||||
|
||||
type HeaderSource = Pick<MessageDetail, 'from' | 'to' | 'cc' | 'subject' | 'receivedAt' | 'sentAt'>
|
||||
|
||||
function dateRow(message: HeaderSource, timeZone?: string): QuoteRow {
|
||||
return { label: '날짜', value: formatMailDateTime(message.sentAt ?? message.receivedAt, timeZone) }
|
||||
}
|
||||
|
||||
function fromRows(message: HeaderSource): QuoteRow[] {
|
||||
return message.from ? [{ label: '보낸 사람', value: formatAddress(message.from) }] : []
|
||||
}
|
||||
|
||||
function subjectRow(message: HeaderSource): QuoteRow {
|
||||
return { label: '제목', value: message.subject || NO_SUBJECT }
|
||||
}
|
||||
|
||||
/** 답장 머리 — 보낸 사람 · 날짜 · 제목 */
|
||||
export function replyQuoteRows(message: HeaderSource, timeZone?: string): QuoteRow[] {
|
||||
return [...fromRows(message), dateRow(message, timeZone), subjectRow(message)]
|
||||
}
|
||||
|
||||
/** 전달 머리 — 보낸 사람 · 날짜 · 받는 사람 · 참조 · 제목 */
|
||||
export function forwardQuoteRows(message: HeaderSource, timeZone?: string): QuoteRow[] {
|
||||
const rows: QuoteRow[] = [...fromRows(message), dateRow(message, timeZone)]
|
||||
if (message.to.length > 0) rows.push({ label: '받는 사람', value: formatAddressList(message.to) })
|
||||
if (message.cc.length > 0) rows.push({ label: '참조', value: formatAddressList(message.cc) })
|
||||
rows.push(subjectRow(message))
|
||||
return rows
|
||||
}
|
||||
|
||||
/** 내가 쓸 빈 문단 + "원본 메일" 머리 + 원문을 담은 인용 */
|
||||
export function buildQuoteHtml(rows: readonly QuoteRow[], bodyHtml: string): string {
|
||||
const header = rows
|
||||
.map((row) => `<tr><td>${escapeHtml(row.label)}</td><td>${escapeHtml(row.value)}</td></tr>`)
|
||||
.join('')
|
||||
return (
|
||||
'<p></p>' +
|
||||
'<blockquote data-mail-quote="">' +
|
||||
`<p data-quote-header=""><strong>${escapeHtml(MAIL_QUOTE_HEADING)}</strong></p>` +
|
||||
`<table data-quote-header=""><tbody>${header}</tbody></table>` +
|
||||
bodyHtml +
|
||||
'</blockquote>'
|
||||
)
|
||||
}
|
||||
@@ -1,17 +1,8 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import {
|
||||
MAIL_BODY_MAX_LENGTH,
|
||||
MAIL_QUOTE_OMITTED_MARK,
|
||||
MAIL_SUBJECT_MAX_LENGTH,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAIL_SUBJECT_MAX_LENGTH } from '@/shared/constants/mail'
|
||||
import type { MessageDetail } from '@/shared/types/mail'
|
||||
import {
|
||||
buildQuotedBody,
|
||||
buildReplyDraft,
|
||||
buildReplyRecipients,
|
||||
buildReplySubject,
|
||||
} from './mail-reply'
|
||||
import { buildReplyDraft, buildReplyRecipients, buildReplySubject } from './mail-reply'
|
||||
|
||||
const ME = 'ara.kim@arasystem.it.kr'
|
||||
const DOYUN = { name: '이도윤', email: 'doyun.lee@arasystem.it.kr' }
|
||||
@@ -84,41 +75,49 @@ describe('buildReplyRecipients', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildQuotedBody', () => {
|
||||
it('구분선 + "날짜, 보낸 사람 작성:" + 인용 (디자인 원본 Compose)', () => {
|
||||
expect(buildQuotedBody(message(), 'Asia/Seoul')).toBe(
|
||||
[
|
||||
'',
|
||||
'',
|
||||
'──────────',
|
||||
'2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@arasystem.it.kr> 작성:',
|
||||
'> 김아라님, 안녕하세요.',
|
||||
'>',
|
||||
'> 금요일 저녁에 전환하겠습니다.',
|
||||
].join('\n'),
|
||||
)
|
||||
})
|
||||
|
||||
it('텍스트 본문이 없으면 HTML 을 텍스트로 바꿔 인용한다', () => {
|
||||
const body = buildQuotedBody(
|
||||
message({ textBody: null, htmlBody: '<p>첫 줄<br>둘째 줄</p>' }),
|
||||
'Asia/Seoul',
|
||||
)
|
||||
|
||||
expect(body.endsWith('> 첫 줄\n> 둘째 줄')).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
describe('buildReplyDraft', () => {
|
||||
it('받는 사람 · Re: 제목 · 인용 본문 · inReplyTo(메일 id) 를 채운다', () => {
|
||||
it('받는 사람 · Re: 제목 · inReplyTo(메일 id) 를 채운다', () => {
|
||||
const draft = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul')
|
||||
|
||||
expect(draft.to).toEqual([DOYUN])
|
||||
expect(draft.subject).toBe('Re: 메일 서버 이전 일정 공유')
|
||||
expect(draft.body).toContain('> 금요일 저녁에 전환하겠습니다.')
|
||||
expect(draft.inReplyTo).toBe('m1')
|
||||
})
|
||||
|
||||
it('본문 — 맨 위 빈 줄 + "원본 메일" 머리(보낸 사람 · 날짜 · 제목) + 원문을 인용 막대 안에 (디자인 README "원문 인용")', () => {
|
||||
const { html } = buildReplyDraft(message(), ME, 'reply', 'Asia/Seoul')
|
||||
|
||||
expect(html).toBe(
|
||||
'<p></p><blockquote data-mail-quote="">' +
|
||||
'<p data-quote-header=""><strong>원본 메일</strong></p>' +
|
||||
'<table data-quote-header=""><tbody>' +
|
||||
'<tr><td>보낸 사람</td><td>이도윤 <doyun.lee@arasystem.it.kr></td></tr>' +
|
||||
'<tr><td>날짜</td><td>2026. 10. 26. (월) 오전 10:42</td></tr>' +
|
||||
'<tr><td>제목</td><td>메일 서버 이전 일정 공유</td></tr>' +
|
||||
'</tbody></table>' +
|
||||
'<p>김아라님, 안녕하세요.</p><p></p><p>금요일 저녁에 전환하겠습니다.</p>' +
|
||||
'</blockquote>',
|
||||
)
|
||||
})
|
||||
|
||||
it('답장 머리에는 받는 사람 · 참조가 없다 (전달에만)', () => {
|
||||
const { html } = buildReplyDraft(message({ cc: [INFRA] }), ME, 'reply', 'Asia/Seoul')
|
||||
|
||||
expect(html).not.toContain('<td>받는 사람</td>')
|
||||
expect(html).not.toContain('<td>참조</td>')
|
||||
})
|
||||
|
||||
it('서식 본문(htmlBody)이 있으면 그것을 인용한다 — 편집기가 모르는 요소는 편집기가 뺀다', () => {
|
||||
const { html } = buildReplyDraft(
|
||||
message({ htmlBody: '<p><strong>금요일</strong> 저녁</p>', textBody: '텍스트' }),
|
||||
ME,
|
||||
'reply',
|
||||
)
|
||||
|
||||
expect(html).toContain('<p><strong>금요일</strong> 저녁</p></blockquote>')
|
||||
expect(html).not.toContain('텍스트')
|
||||
})
|
||||
|
||||
it('계약 한도에 맞춘다 — 원래 제목 251자면 "Re: " 를 붙여 255자, 252자면 255자에서 자른다', () => {
|
||||
const fits = buildReplyDraft(message({ subject: '가'.repeat(251) }), ME, 'reply')
|
||||
const over = buildReplyDraft(message({ subject: '가'.repeat(252) }), ME, 'reply')
|
||||
@@ -126,13 +125,4 @@ describe('buildReplyDraft', () => {
|
||||
expect(fits.subject).toBe(`Re: ${'가'.repeat(251)}`)
|
||||
expect(over.subject).toHaveLength(MAIL_SUBJECT_MAX_LENGTH)
|
||||
})
|
||||
|
||||
it('인용이 본문 한도(100,000자)를 넘으면 인용을 줄이고 인용 안에 생략 표시를 둔다', () => {
|
||||
const longText = '긴 원문 줄입니다.\n'.repeat(20_000)
|
||||
const draft = buildReplyDraft(message({ textBody: longText }), ME, 'reply')
|
||||
|
||||
expect(draft.body.length).toBeLessThanOrEqual(MAIL_BODY_MAX_LENGTH)
|
||||
expect(draft.body.startsWith('\n\n──────────\n')).toBe(true)
|
||||
expect(draft.body.endsWith(`\n> ${MAIL_QUOTE_OMITTED_MARK}`)).toBe(true)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -1,15 +1,12 @@
|
||||
// 답장 초안 만들기 — 순수 함수만 둔다 (htmlToText 는 DOMParser 를 쓰지만 결과는 입력에만 의존한다)
|
||||
// 답장 초안 만들기 — 순수 함수만 둔다
|
||||
// 본문은 서식 편집기에 넣을 HTML 이다 — "원본 메일" 머리(보낸 사람·날짜·제목)와 회색 인용 막대 (mail-quote.ts).
|
||||
// 본문 한도(텍스트 100,000 · 서식 500,000)에 맞추는 일은 편집기가 한다 (components/mail/editor/fit-draft.ts).
|
||||
|
||||
import {
|
||||
MAIL_QUOTE_LINE_PREFIX,
|
||||
MAIL_QUOTE_SEPARATOR,
|
||||
MAIL_REPLY_PREFIX,
|
||||
} from '@/shared/constants/mail'
|
||||
import { MAIL_REPLY_PREFIX } from '@/shared/constants/mail'
|
||||
import type { Address, MessageDetail } from '@/shared/types/mail'
|
||||
import { formatMailDateTime } from '@/shared/utils/format'
|
||||
import { formatAddress, normalizeEmail, sameAddress, uniqueAddresses } from '@/shared/utils/mail-address'
|
||||
import { fitDraftBody, fitDraftSubject } from '@/shared/utils/mail-draft'
|
||||
import { messageBodyText } from '@/shared/utils/mail-html'
|
||||
import { normalizeEmail, sameAddress, uniqueAddresses } from '@/shared/utils/mail-address'
|
||||
import { fitDraftSubject } from '@/shared/utils/mail-draft'
|
||||
import { buildQuoteHtml, originalBodyHtml, replyQuoteRows } from '@/shared/utils/mail-quote'
|
||||
|
||||
export type ReplyMode = 'reply' | 'replyAll'
|
||||
|
||||
@@ -17,7 +14,8 @@ export interface ReplyDraft {
|
||||
to: Address[]
|
||||
cc: Address[]
|
||||
subject: string
|
||||
body: string
|
||||
/** 편집기에 넣을 본문 — 맨 위 빈 줄(여기에 쓴다) + 원문 인용 */
|
||||
html: string
|
||||
/** 발송 본문의 inReplyTo — 답장할 메일의 id */
|
||||
inReplyTo: string
|
||||
}
|
||||
@@ -61,31 +59,7 @@ export function buildReplyRecipients(
|
||||
return { to, cc }
|
||||
}
|
||||
|
||||
type QuoteSource = Pick<MessageDetail, 'from' | 'receivedAt' | 'sentAt' | 'textBody' | 'htmlBody'>
|
||||
|
||||
/**
|
||||
* 인용 본문 (디자인 원본 Compose):
|
||||
*
|
||||
* (빈 줄 — 여기에 답장을 쓴다)
|
||||
*
|
||||
* ──────────
|
||||
* 2026. 10. 26. (월) 오전 10:42, 이도윤 <doyun.lee@…> 작성:
|
||||
* > 원문 첫 줄
|
||||
*/
|
||||
export function buildQuotedBody(message: QuoteSource, timeZone?: string): string {
|
||||
const author = message.from ? formatAddress(message.from) : ''
|
||||
const date = formatMailDateTime(message.sentAt ?? message.receivedAt, timeZone)
|
||||
const header = [date, author].filter(Boolean).join(', ')
|
||||
|
||||
const quoted = messageBodyText(message)
|
||||
.split('\n')
|
||||
.map((line) => (line ? `${MAIL_QUOTE_LINE_PREFIX}${line}` : MAIL_QUOTE_LINE_PREFIX.trimEnd()))
|
||||
.join('\n')
|
||||
|
||||
return `\n\n${MAIL_QUOTE_SEPARATOR}\n${header} 작성:\n${quoted}`
|
||||
}
|
||||
|
||||
/** 답장 초안 — 제목·본문은 계약 한도(255자·100,000자)에 맞춘다. 넘치면 인용을 줄이고 인용 안에 생략 표시를 둔다 */
|
||||
/** 답장 초안 — 제목은 255자에 맞춘다 */
|
||||
export function buildReplyDraft(
|
||||
message: MessageDetail,
|
||||
myEmail: string,
|
||||
@@ -97,7 +71,7 @@ export function buildReplyDraft(
|
||||
to,
|
||||
cc,
|
||||
subject: fitDraftSubject(buildReplySubject(message.subject)),
|
||||
body: fitDraftBody(buildQuotedBody(message, timeZone), MAIL_QUOTE_LINE_PREFIX),
|
||||
html: buildQuoteHtml(replyQuoteRows(message, timeZone), originalBodyHtml(message)),
|
||||
inReplyTo: message.id,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
import { MAIL_HTML_STYLES } from '@/components/mail/editor/mail-serialize'
|
||||
import {
|
||||
EDITOR_BACKGROUND_COLORS,
|
||||
EDITOR_FONTS,
|
||||
EDITOR_FONT_SIZES,
|
||||
EDITOR_INDENT_STEP_PX,
|
||||
EDITOR_MAX_INDENT,
|
||||
EDITOR_TEXT_COLORS,
|
||||
MAIL_BODY_FONT_FAMILY,
|
||||
MAIL_BODY_FONT_SIZE_PX,
|
||||
MAIL_BODY_LINE_HEIGHT,
|
||||
} from '@/shared/constants/mail-editor'
|
||||
import { STYLE_SAMPLES } from '@/test-utils/outgoing-style-samples'
|
||||
import {
|
||||
acceptStyleValue,
|
||||
cellSpanOf,
|
||||
isOutgoingStyleValue,
|
||||
lengthToPx,
|
||||
listStartOf,
|
||||
} from './outgoing-html-rules'
|
||||
|
||||
describe('isOutgoingStyleValue — 서버 정화 허용 목록과 같은 값 형식 (계약 2절 "서식 본문")', () => {
|
||||
it.each(STYLE_SAMPLES)('%s: %s → 남는다 %s', (property, value, kept) => {
|
||||
expect(isOutgoingStyleValue(property, value)).toBe(kept)
|
||||
})
|
||||
|
||||
it('속성 이름은 작은 글자만 맞는다 — 객체의 기본 속성 이름도 규칙이 아니다', () => {
|
||||
expect(isOutgoingStyleValue('COLOR', '#fff')).toBe(false)
|
||||
expect(isOutgoingStyleValue('constructor', 'x')).toBe(false)
|
||||
expect(isOutgoingStyleValue('__proto__', 'x')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('편집기가 만드는 서식은 모두 서버 형식 안이다 — 보내면 그대로 남는다', () => {
|
||||
it('글꼴 목록 · 기본 글꼴 · 글자 크기 · 팔레트 색 · 들여쓰기 · 줄 간격', () => {
|
||||
for (const font of EDITOR_FONTS) expect(isOutgoingStyleValue('font-family', font.family)).toBe(true)
|
||||
expect(isOutgoingStyleValue('font-family', MAIL_BODY_FONT_FAMILY)).toBe(true)
|
||||
for (const size of [...EDITOR_FONT_SIZES, MAIL_BODY_FONT_SIZE_PX]) {
|
||||
expect(isOutgoingStyleValue('font-size', `${size}px`)).toBe(true)
|
||||
}
|
||||
for (const color of [...EDITOR_TEXT_COLORS, ...EDITOR_BACKGROUND_COLORS]) {
|
||||
if (color.value !== null) expect(isOutgoingStyleValue('color', color.value)).toBe(true)
|
||||
}
|
||||
expect(isOutgoingStyleValue('margin-left', `${EDITOR_INDENT_STEP_PX * EDITOR_MAX_INDENT}px`)).toBe(true)
|
||||
expect(isOutgoingStyleValue('line-height', String(MAIL_BODY_LINE_HEIGHT))).toBe(true)
|
||||
})
|
||||
|
||||
it('받는 쪽 모양(MAIL_HTML_STYLES)의 선언 하나하나', () => {
|
||||
for (const style of Object.values(MAIL_HTML_STYLES)) {
|
||||
for (const declaration of style.split(';')) {
|
||||
const [property = '', ...value] = declaration.split(':')
|
||||
expect(isOutgoingStyleValue(property.trim(), value.join(':')), declaration).toBe(true)
|
||||
}
|
||||
}
|
||||
})
|
||||
})
|
||||
|
||||
describe('acceptStyleValue — 들어온 값(붙여 넣기 · 원문 인용)을 보낼 수 있는 값으로', () => {
|
||||
it('형식에 맞으면 그대로, 틀리면 null — !important 는 떼고 본다', () => {
|
||||
expect(acceptStyleValue('color', ' #B42318 ')).toBe('#B42318')
|
||||
expect(acceptStyleValue('color', '#B42318 !important')).toBe('#B42318')
|
||||
expect(acceptStyleValue('color', 'transparent')).toBeNull()
|
||||
expect(acceptStyleValue('font-family', 'Calibri, sans-serif')).toBeNull()
|
||||
expect(acceptStyleValue('color', null)).toBeNull()
|
||||
})
|
||||
|
||||
it('글자 크기 — pt 는 px 로, 소수 셋째 자리부터는 반올림 (워드 · 아웃룩)', () => {
|
||||
expect(acceptStyleValue('font-size', '10pt')).toBe('13.33px')
|
||||
expect(acceptStyleValue('font-size', '12.0pt')).toBe('16px')
|
||||
expect(acceptStyleValue('font-size', '13.3333px')).toBe('13.33px')
|
||||
expect(acceptStyleValue('font-size', '18PX')).toBe('18px')
|
||||
expect(acceptStyleValue('font-size', '0')).toBeNull()
|
||||
expect(acceptStyleValue('font-size', '0pt')).toBeNull()
|
||||
expect(acceptStyleValue('font-size', '1.2em')).toBeNull()
|
||||
expect(acceptStyleValue('font-size', '80pt')).toBeNull()
|
||||
})
|
||||
|
||||
it('lengthToPx — px · pt 만 (기준 크기를 모르는 em · % 는 null)', () => {
|
||||
expect(lengthToPx('40px')).toBe(40)
|
||||
expect(lengthToPx('36pt')).toBe(48)
|
||||
expect(lengthToPx('2em')).toBeNull()
|
||||
expect(lengthToPx('-10px')).toBeNull()
|
||||
})
|
||||
})
|
||||
|
||||
describe('숫자 속성 — 서버가 남기는 1~4자리 숫자', () => {
|
||||
it('목록 시작 번호 — 0~9999, 그 밖은 1', () => {
|
||||
expect(listStartOf('3')).toBe(3)
|
||||
expect(listStartOf(0)).toBe(0)
|
||||
expect(listStartOf(9999)).toBe(9999)
|
||||
for (const value of [10_000, -2, '3x', '2;', Number.NaN, 2.5, null, undefined]) expect(listStartOf(value)).toBe(1)
|
||||
})
|
||||
|
||||
it('칸 합치기 — 1~1000, 형식이 틀리면 1', () => {
|
||||
expect(cellSpanOf('2')).toBe(2)
|
||||
expect(cellSpanOf('5000')).toBe(1000)
|
||||
for (const value of ['0', '2;', 'abc', '', null, -1, 10_000]) expect(cellSpanOf(value)).toBe(1)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,185 @@
|
||||
// 보내는 서식 본문(bodyHtml)에 남는 값의 형식 — 순수 함수 (계약 2절 "서식 본문")
|
||||
//
|
||||
// ⚠️ 서버 정화 허용 목록과 **같은 형식**이다. 짝: backend/src/modules/mail/html/sanitize-outgoing-html.ts 의
|
||||
// STYLE_RULES · OUTGOING_FONT_NAMES · SMALL_NUMBER. 한쪽을 고치면 다른 쪽도 같이 고친다 —
|
||||
// 같은 표본 표를 양쪽 테스트에 둔다 (test-utils/outgoing-style-samples.ts 의 STYLE_SAMPLES ↔ sanitize-outgoing-html.spec.ts 의 "값 형식 표본").
|
||||
//
|
||||
// 쓰는 곳: 서식 편집기가 붙여 넣은 글 · 원문 인용을 읽어 들일 때 이 형식에 맞는 값만 받는다 (editor/mail-format-extensions.ts).
|
||||
// 서버만 지우면 편집기에서만 보이는(또는 숨는) 모양이 생긴다 — 예: font-size:0 으로 숨긴 글이 받는 사람에게는 보인다.
|
||||
// 그래서 편집기에서도 처음부터 버린다 — 편집기에 보이는 모양 = 받는 사람이 보는 모양.
|
||||
|
||||
// ── style 값 형식 (서버 STYLE_RULES 와 같다 — 모두 처음부터 끝까지, 대소문자 무시) ──────────────
|
||||
|
||||
/** px 길이 — 0 또는 0~999.99px. 음수는 받지 않는다 */
|
||||
const PX = String.raw`(?:0|\d{1,3}(?:\.\d{1,2})?px)`
|
||||
/** 칸 폭 — px · % · auto */
|
||||
const WIDTH = String.raw`(?:${PX}|\d{1,3}(?:\.\d{1,2})?%|auto)`
|
||||
/** 색 — #rgb · #rrggbb · rgb()/rgba() (괄호 안은 숫자만) */
|
||||
const COLOR = String.raw`(?:#[0-9a-f]{3}|#[0-9a-f]{6}|rgba?\(\s*\d{1,3}\s*,\s*\d{1,3}\s*,\s*\d{1,3}\s*(?:,\s*(?:0|1|0?\.\d{1,3})\s*)?\))`
|
||||
const BORDER_STYLE = String.raw`(?:none|hidden|dotted|dashed|solid|double)`
|
||||
const BORDER_WIDTH = String.raw`(?:${PX}|thin|medium|thick)`
|
||||
const BORDER_PART = String.raw`(?:${BORDER_WIDTH}|${BORDER_STYLE}|${COLOR})`
|
||||
/** 글자 크기 — px 만, 1~99.99px */
|
||||
const FONT_SIZE = String.raw`(?:[1-9]\d?(?:\.\d{1,2})?px)`
|
||||
/** 줄 간격 — 단위 없는 숫자 1~3, 소수 둘째 자리까지 */
|
||||
const LINE_HEIGHT = String.raw`(?:[12](?:\.\d{1,2})?|3(?:\.0{1,2})?)`
|
||||
|
||||
/**
|
||||
* 받는 글꼴 이름 — 쓰기 화면의 글꼴 목록 · 기본 스택과 흔히 함께 적는 대체 글꼴 · 일반 글꼴 이름.
|
||||
* 목록 밖 이름(Calibri 등)이 하나라도 섞인 font-family 는 통째로 받지 않는다 (서버와 같다).
|
||||
*/
|
||||
export const OUTGOING_FONT_NAMES = [
|
||||
'Malgun Gothic',
|
||||
'맑은 고딕',
|
||||
'Apple SD Gothic Neo',
|
||||
'Gulim',
|
||||
'굴림',
|
||||
'Dotum',
|
||||
'돋움',
|
||||
'Batang',
|
||||
'바탕',
|
||||
'AppleGothic',
|
||||
'AppleMyungjo',
|
||||
'Arial',
|
||||
'Helvetica',
|
||||
'Verdana',
|
||||
'Georgia',
|
||||
'Times New Roman',
|
||||
'Times',
|
||||
'Courier New',
|
||||
'Courier',
|
||||
'sans-serif',
|
||||
'serif',
|
||||
'monospace',
|
||||
] as const
|
||||
|
||||
const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
|
||||
/** 글꼴 이름 하나 — 그대로 · 작은따옴표 · 큰따옴표 (긴 이름부터 맞춰 본다) */
|
||||
const FONT_NAME = (() => {
|
||||
const names = [...OUTGOING_FONT_NAMES]
|
||||
.sort((a, b) => b.length - a.length)
|
||||
.map(escapeRegExp)
|
||||
.join('|')
|
||||
return `(?:(?:${names})|'(?:${names})'|"(?:${names})")`
|
||||
})()
|
||||
|
||||
const exactly = (pattern: string) => new RegExp(`^${pattern}$`, 'i')
|
||||
|
||||
const COLOR_VALUE = exactly(COLOR)
|
||||
const PX_VALUE = exactly(PX)
|
||||
const WIDTH_VALUE = exactly(WIDTH)
|
||||
/** margin · padding 줄임형 — px 길이 1~4개 */
|
||||
const BOX_VALUE = exactly(String.raw`${PX}(?:\s+${PX}){0,3}`)
|
||||
const BORDER_VALUE = exactly(String.raw`${BORDER_PART}(?:\s+${BORDER_PART}){0,2}`)
|
||||
|
||||
/**
|
||||
* 서버가 남기는 style 속성과 값 형식 — 어느 태그에 오든 같은 규칙이다.
|
||||
* 여기 없는 속성(display · position · visibility · opacity · text-decoration 등)은 서버가 지운다.
|
||||
*/
|
||||
export const OUTGOING_STYLE_RULES = {
|
||||
color: COLOR_VALUE,
|
||||
'background-color': COLOR_VALUE,
|
||||
'font-family': exactly(String.raw`${FONT_NAME}(?:\s*,\s*${FONT_NAME}){0,9}`),
|
||||
'font-size': exactly(FONT_SIZE),
|
||||
'font-weight': exactly('(?:normal|bold|[1-9]00)'),
|
||||
'text-align': exactly('(?:left|right|center|justify)'),
|
||||
margin: BOX_VALUE,
|
||||
'margin-top': PX_VALUE,
|
||||
'margin-right': PX_VALUE,
|
||||
'margin-bottom': PX_VALUE,
|
||||
'margin-left': PX_VALUE,
|
||||
padding: BOX_VALUE,
|
||||
'padding-top': PX_VALUE,
|
||||
'padding-right': PX_VALUE,
|
||||
'padding-bottom': PX_VALUE,
|
||||
'padding-left': PX_VALUE,
|
||||
border: BORDER_VALUE,
|
||||
'border-top': BORDER_VALUE,
|
||||
'border-right': BORDER_VALUE,
|
||||
'border-bottom': BORDER_VALUE,
|
||||
'border-left': BORDER_VALUE,
|
||||
'border-color': COLOR_VALUE,
|
||||
'border-style': exactly(BORDER_STYLE),
|
||||
'border-width': exactly(BORDER_WIDTH),
|
||||
'border-collapse': exactly('(?:collapse|separate)'),
|
||||
width: WIDTH_VALUE,
|
||||
'min-width': WIDTH_VALUE,
|
||||
'vertical-align': exactly('(?:top|middle|bottom|baseline)'),
|
||||
'white-space': exactly('(?:normal|nowrap)'),
|
||||
'line-height': exactly(LINE_HEIGHT),
|
||||
} as const satisfies Record<string, RegExp>
|
||||
|
||||
export type OutgoingStyleProperty = keyof typeof OUTGOING_STYLE_RULES
|
||||
|
||||
/**
|
||||
* 서버가 남기는 style 선언인가.
|
||||
* value 는 서버가 보는 값과 같은 모양으로 넘긴다 — 앞뒤 공백은 괜찮고 `!important` 는 뗀 값 (CSS 파서가 값과 나눠 읽는다).
|
||||
* 속성 이름은 작은 글자만 맞는다 (서버도 이름을 대소문자 가려 비교한다).
|
||||
*/
|
||||
export function isOutgoingStyleValue(property: string, value: string): boolean {
|
||||
if (!Object.hasOwn(OUTGOING_STYLE_RULES, property)) return false
|
||||
return OUTGOING_STYLE_RULES[property as OutgoingStyleProperty].test(value.trim())
|
||||
}
|
||||
|
||||
// ── 들어오는 style 값 (붙여 넣은 글 · 원문 인용) ─────────────────────────────
|
||||
|
||||
const IMPORTANT = /\s*!\s*important\s*$/i
|
||||
/** px · pt 길이 */
|
||||
const LENGTH = /^(\d+(?:\.\d+)?)(px|pt)$/i
|
||||
/** 워드 · 한글이 쓰는 pt → px (1pt = 4/3px) */
|
||||
const PX_PER_PT = 4 / 3
|
||||
/** 소수 둘째 자리까지 — 서버 형식이 그 이상을 받지 않는다 */
|
||||
const roundTo2 = (value: number) => Math.round(value * 100) / 100
|
||||
|
||||
/** 값 끝의 `!important` 를 떼고 앞뒤 공백을 지운다 */
|
||||
export function stripImportant(value: string): string {
|
||||
return value.replace(IMPORTANT, '').trim()
|
||||
}
|
||||
|
||||
/** px · pt 길이 → px 숫자 (0 이상). 그 밖의 단위(em · rem · % 등)는 기준 크기를 알 수 없어 null */
|
||||
export function lengthToPx(value: string): number | null {
|
||||
const match = LENGTH.exec(stripImportant(value))
|
||||
if (!match?.[1] || !match[2]) return null
|
||||
const amount = Number(match[1])
|
||||
return match[2].toLowerCase() === 'pt' ? amount * PX_PER_PT : amount
|
||||
}
|
||||
|
||||
/**
|
||||
* 들어온 style 값 → 보낼 수 있는 값. 형식이 틀리면 null (그 서식은 받지 않는다).
|
||||
* - `!important` 는 떼고 본다 (서버도 값만 본다)
|
||||
* - 글자 크기: pt 는 px 로 바꾸고 소수 셋째 자리부터는 반올림한다 (워드 10pt → 13.33px, 아웃룩의 13.3333px)
|
||||
*/
|
||||
export function acceptStyleValue(property: OutgoingStyleProperty, raw: string | null | undefined): string | null {
|
||||
if (typeof raw !== 'string') return null
|
||||
let value = stripImportant(raw)
|
||||
if (property === 'font-size') {
|
||||
const px = lengthToPx(value)
|
||||
if (px !== null) value = `${roundTo2(px)}px`
|
||||
}
|
||||
return isOutgoingStyleValue(property, value) ? value : null
|
||||
}
|
||||
|
||||
// ── 숫자 속성 (서버 SMALL_NUMBER — 목록 시작 번호 · 칸 합치기) ──────────────────
|
||||
|
||||
/** 서버가 남기는 숫자 속성 값 — 숫자 1~4자리 */
|
||||
const SMALL_NUMBER = /^\d{1,4}$/
|
||||
/** 칸 합치기 상한 — HTML 의 colspan 상한과 같다 (그보다 크면 브라우저도 1000 으로 본다) */
|
||||
const CELL_SPAN_MAX = 1000
|
||||
|
||||
function smallNumber(value: unknown): number | null {
|
||||
const text = typeof value === 'number' ? String(value) : value
|
||||
if (typeof text !== 'string' || !SMALL_NUMBER.test(text.trim())) return null
|
||||
return Number(text.trim())
|
||||
}
|
||||
|
||||
/** 번호 목록의 시작 번호 — 서버가 남기는 값(0~9999)이 아니면 1 (받는 쪽도 1 부터 보인다) */
|
||||
export function listStartOf(value: unknown): number {
|
||||
return smallNumber(value) ?? 1
|
||||
}
|
||||
|
||||
/** 칸 합치기(colspan · rowspan) — 1~1000. 형식이 틀리면 1 (서버가 지우면 받는 쪽도 1 칸이다) */
|
||||
export function cellSpanOf(value: unknown): number {
|
||||
const span = smallNumber(value)
|
||||
return span === null || span < 1 ? 1 : Math.min(span, CELL_SPAN_MAX)
|
||||
}
|
||||
@@ -48,6 +48,21 @@ a:hover {
|
||||
outline-offset: var(--focus-ring-offset);
|
||||
}
|
||||
|
||||
/*
|
||||
* 글자 입력칸 포커스 — 한 가지 규칙 (디자인 README "메일 쓰기 · 글자 입력칸 포커스")
|
||||
* 받는 사람·참조·숨은참조 행, 제목 행, 본문 편집 영역에 .compose-field 를 붙인다.
|
||||
* 쓰는 곳에 커서가 있으면 그 행의 아래 1px 선이 2px 브랜드색이 된다 (선 색 + 안쪽 1px — 높이가 변하지 않는다).
|
||||
* 행 안의 버튼(토큰 ✕ · "참조 · 숨은참조")에 포커스가 가면 버튼 고리만 보이고 선은 회색 그대로다.
|
||||
*/
|
||||
.compose-field:has(:is(input, .ProseMirror):focus) {
|
||||
border-bottom-color: var(--color-brand);
|
||||
box-shadow: inset 0 calc(var(--border-width) * -1) 0 var(--color-brand);
|
||||
}
|
||||
|
||||
.compose-field :is(input, .ProseMirror):focus-visible {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* 화면에는 숨기고 스크린리더에는 읽힌다 (안 읽음·준비 중 같은 상태 전달용) */
|
||||
.visually-hidden {
|
||||
position: absolute;
|
||||
|
||||
@@ -160,4 +160,28 @@
|
||||
--content-toolbar-start: calc(var(--content-inline) - var(--toolbar-button-inline));
|
||||
/* 744px — 쓰기 본문 입력칸 = 글줄 680 + 좌우 여백 */
|
||||
--compose-body-max-width: calc(var(--content-measure) + var(--content-inline) * 2);
|
||||
|
||||
/*
|
||||
* ── 메일 쓰기 서식 편집기 (디자인 README "메일 쓰기", 2026-10-27 확정) ──
|
||||
* 아래 네 크기는 클릭 영역 44px 기준의 의도된 예외다 — 사용자 확정 (디자인 README "디자인 원칙").
|
||||
* 툴바 아이콘 버튼 40×44 · 메뉴 항목 높이 40 · 색 견본 칸 40×40 · 표 고르기 칸 22(간격 3, 키보드는 화살표)
|
||||
*/
|
||||
--size-editor-tool: 2.5rem; /* 40px — 툴바 아이콘 버튼 폭 (높이는 --size-touch 44px) · 밀도 예외 */
|
||||
--size-editor-menu-item: 2.5rem; /* 40px — 메뉴 항목 높이 · 밀도 예외 */
|
||||
--size-editor-swatch-cell: 2.5rem; /* 40px — 색 견본 칸 · 밀도 예외 */
|
||||
--size-editor-table-cell: 1.375rem; /* 22px — 표 고르기 격자 칸 · 밀도 예외 */
|
||||
--editor-table-cell-gap: 0.1875rem; /* 3px */
|
||||
--size-editor-swatch: 1.625rem; /* 26px — 견본 안에 보이는 색 */
|
||||
--size-editor-font-button: 6.75rem; /* 108px — 글꼴 단추 */
|
||||
--size-editor-size-button: 3.5rem; /* 56px — 글자 크기 단추 */
|
||||
--size-editor-toolbar: 3.25rem; /* 52px — 툴바 높이 (위아래 4) */
|
||||
--size-editor-body-min: 18.75rem; /* 300px — 본문 편집 영역의 최소 높이 (편집기를 불러오는 동안의 자리도 같다) */
|
||||
--editor-toolbar-inline: 1.25rem; /* 20px — 툴바 좌우 여백. 버튼 안 12 를 더하면 첫 아이콘이 32px 선 */
|
||||
--color-editor-pressed-hover: #d5ebed; /* 켜진 버튼에 마우스를 올렸을 때 */
|
||||
--color-quote-bar: #d0d5dd; /* 원문 인용 막대 — 포커스 선(브랜드색)과 겹치지 않게 회색 */
|
||||
--color-drop-surface: rgba(243, 248, 248, 0.94); /* 끌어다 놓는 동안의 덮개 */
|
||||
/* 본문 기본 글꼴 — 맑은 고딕 스택. 보내는 HTML 의 글꼴(shared/constants/mail-editor.ts MAIL_BODY_FONT_FAMILY)과 같다 */
|
||||
--font-family-mail-body: 'Malgun Gothic', '맑은 고딕', 'Apple SD Gothic Neo', sans-serif;
|
||||
/* 본문 줄 간격 — 보내는 HTML 의 줄 간격(shared/constants/mail-editor.ts MAIL_BODY_LINE_HEIGHT)과 같다 */
|
||||
--line-height-mail-body: 1.75;
|
||||
}
|
||||
|
||||
@@ -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