Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
d7887f3
✨ Feat: Quill 에디터 폰트 적용 방식 κ°œμ„  및 νˆ΄λ°” μ˜΅μ…˜ ν™•μž₯
Greensod-96 Oct 13, 2025
a3e4e7b
🎨 Style: λͺ¨λ‹¬ μ—΄λ¦Ό/λ‹«νž˜ μ‹œ λΆ€λ“œλŸ¬μš΄ μ „ν™˜ μ• λ‹ˆλ©”μ΄μ…˜ μΆ”κ°€
Greensod-96 Oct 13, 2025
b8e3d7e
✨ Feat: PostContent μ»΄ν¬λ„ŒνŠΈ 리슀트 및 폰트 λ Œλ”λ§ κ°œμ„ 
Greensod-96 Oct 13, 2025
33a5e62
♻️ Refactor: MessageCard μ»΄ν¬λ„ŒνŠΈμ—μ„œ PostContent μž¬μ‚¬μš©μœΌλ‘œ μ½”λ“œ 쀑볡 제거
Greensod-96 Oct 13, 2025
945a4fd
✨ Feat: 폰트 λ§€ν•‘ 및 클래슀 μƒμˆ˜ 정리 (fontMap, FONT_CLASSES, QUILL_FONT_CLASSES) μΆ”κ°€
Greensod-96 Oct 13, 2025
7a096a9
✨ Feat: SANITIZE_CONFIG ν™•μž₯ β€” 체크리슀트 및 μŠ€νƒ€μΌ 속성 지원 μΆ”κ°€
Greensod-96 Oct 13, 2025
1e99cfb
♻️ Refactor: PostMessage μ»΄ν¬λ„ŒνŠΈ ꡬ쑰 κ°œμ„  및 ν”„λ‘œν•„ 이미지 관리 방식 λ‹¨μˆœν™”
Greensod-96 Oct 13, 2025
2dcb56f
🎨 Style: Quill 체크리슀트 및 에디터 μ˜μ—­ μ»€μŠ€ν…€ μŠ€νƒ€μΌ μΆ”κ°€
Greensod-96 Oct 13, 2025
64cf88c
Merge branch 'main' into fix/profile-image-basic-Delete
Greensod-96 Oct 13, 2025
eaeca19
♻️ Refactor: λΆˆν•„μš”ν•œ μ£Όμ„μ œκ±°
Greensod-96 Oct 13, 2025
21f7638
Merge branch 'fix/profile-image-basic-Delete' of https://github.com/S…
Greensod-96 Oct 13, 2025
5ab9954
Merge branch 'main' into fix/profile-image-basic-Delete
Greensod-96 Oct 13, 2025
de6922e
♻️ Refactor: import DOMpurify μ‚­μ œ
Greensod-96 Oct 13, 2025
4372f08
Merge branch 'fix/profile-image-basic-Delete' of https://github.com/S…
Greensod-96 Oct 13, 2025
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 17 additions & 15 deletions src/components/common/TextEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -183,29 +183,35 @@ const useChecklistToolbarManager = (reactQuillRef) => {

const TextEditor = ({ value, onChange, onFontChange, font }) => {
const reactQuillRef = useRef(null);
const lastSelectionFontRef = useRef(null); // λ§ˆμ§€λ§‰ 선택 μ˜μ—­μ˜ 폰트 좔적
const lastSelectionFontRef = useRef(null);

const handleChange = (content) => {
onChange(content);
};

useFontPersistence(reactQuillRef);
useChecklistToolbarManager(reactQuillRef);

// μ™ΈλΆ€ Dropdownμ—μ„œ μ„ νƒλœ 폰트 β†’ 에디터 전체에 적용
// μ™ΈλΆ€ Dropdownμ—μ„œ μ„ νƒλœ 폰트 β†’ μ„ νƒλœ μ˜μ—­μ—λ§Œ 적용 (μˆ˜μ •λ¨!)
useEffect(() => {
const quill = reactQuillRef.current?.getEditor();
if (!quill || !font) {
return;
}

// 전체 ν…μŠ€νŠΈ λ²”μœ„ κ°€μ Έμ˜€κΈ°
const length = quill.getLength();
if (length <= 1) {
// ν…μŠ€νŠΈκ°€ 없을 λ•ŒλŠ” μ»€μ„œ μœ„μΉ˜μ— 폰트 μ„€μ •
const range = quill.getSelection();

if (!range) {
// ν¬μ»€μŠ€κ°€ μ—†μœΌλ©΄ λ‹€μŒ μž…λ ₯에 적용될 포맷만 μ„€μ •
quill.format('font', font, 'api');
} else if (range.length === 0) {
// μ»€μ„œλ§Œ 있고 선택 μ˜μ—­μ΄ μ—†μœΌλ©΄ λ‹€μŒ μž…λ ₯에 적용
quill.format('font', font, 'api');
} else {
// ν…μŠ€νŠΈκ°€ μžˆμ„ λ•ŒλŠ” 전체에 폰트 적용
quill.formatText(0, length, { font }, 'api');
// 선택 μ˜μ—­μ΄ 있으면 ν•΄λ‹Ή μ˜μ—­μ—λ§Œ 폰트 적용
quill.formatText(range.index, range.length, { font }, 'api');
}

// μ™ΈλΆ€μ—μ„œ ν°νŠΈκ°€ λ³€κ²½λ˜λ©΄ ref도 μ—…λ°μ΄νŠΈ
lastSelectionFontRef.current = font;
}, [font]);

Expand All @@ -217,12 +223,10 @@ const TextEditor = ({ value, onChange, onFontChange, font }) => {
}

const handleTextChange = (delta, oldDelta, source) => {
// 'user' μ†ŒμŠ€λŠ” μ‚¬μš©μž μž…λ ₯, 'api' μ†ŒμŠ€λŠ” ν”„λ‘œκ·Έλž˜λ° 방식 λ³€κ²½
if (source !== 'user') {
return;
}

// 폰트 변경이 ν¬ν•¨λœ 경우만 체크
const hasFontChange = delta.ops?.some((op) => op.attributes?.font);
if (!hasFontChange) {
return;
Expand Down Expand Up @@ -260,16 +264,13 @@ const TextEditor = ({ value, onChange, onFontChange, font }) => {
.trim();

const hasContent = () => {
// ν…μŠ€νŠΈκ°€ μžˆλŠ”μ§€ 확인
if (getVisibleText().length > 0) {
return true;
}
// 리슀트 μš”μ†Œκ°€ μžˆλŠ”μ§€ 확인
const hasLists = root.querySelector('ol, ul');
if (hasLists) {
return true;
}
// μ΄λ―Έμ§€λ‚˜ λ‹€λ₯Έ 블둝 μš”μ†Œκ°€ μžˆλŠ”μ§€ 확인
const hasBlocks = root.querySelector('img, iframe, video');
if (hasBlocks) {
return true;
Expand Down Expand Up @@ -312,6 +313,7 @@ const TextEditor = ({ value, onChange, onFontChange, font }) => {
toolbar: {
container: [
[{ font: Font.whitelist }],
[{ size: ['small', false, 'large', 'huge'] }],
['bold', 'italic', 'underline', 'strike'],
[{ align: [] }],
[{ list: 'ordered' }, { list: 'bullet' }, { list: 'check' }],
Expand Down Expand Up @@ -339,7 +341,7 @@ const TextEditor = ({ value, onChange, onFontChange, font }) => {
ref={reactQuillRef}
theme="snow"
value={value || ''}
onChange={onChange}
onChange={handleChange}
modules={modules}
placeholder="여기에 λ‚΄μš©μ„ μž…λ ₯ν•΄μ£Όμ„Έμš”."
/>
Expand Down
30 changes: 22 additions & 8 deletions src/components/common/modal/Modal.jsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect } from 'react';
import { useEffect, useState } from 'react';
import Button from '@/components/common/button/Button';
import ModalHeader from '@/components/common/modal/ModalHeader';
import PostContent from '@/components/common/modal/PostContent';
Expand All @@ -14,6 +14,18 @@ const Modal = ({
createdAt,
font,
}) => {
const [visible, setVisible] = useState(false);

useEffect(() => {
if (isOpen) {
setVisible(true);
} else {
// λ‹«νž λ•Œ μ• λ‹ˆλ©”μ΄μ…˜ κ³ λ €ν•΄ μ•½κ°„μ˜ μ§€μ—°
const timer = setTimeout(() => setVisible(false), 200);
return () => clearTimeout(timer);
Comment on lines +23 to +25
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

high

λͺ¨λ‹¬μ΄ λ‹«νž λ•Œ setTimeout을 μ‚¬μš©ν•˜λŠ” 것은 μ• λ‹ˆλ©”μ΄μ…˜μ΄ μ™„λ£Œλ˜κΈ° 전에 Portal이 제거될 수 μžˆλŠ” 잠재적인 문제λ₯Ό μ•ΌκΈ°ν•  수 μžˆμŠ΅λ‹ˆλ‹€. setVisible(false)λ₯Ό ν˜ΈμΆœν•˜κΈ° 전에 μ• λ‹ˆλ©”μ΄μ…˜μ΄ μ™„λ£Œλ˜μ—ˆλŠ”μ§€ ν™•μΈν•˜λŠ” 것이 μ’‹μŠ΅λ‹ˆλ‹€. CSS μ• λ‹ˆλ©”μ΄μ…˜ 이벀트λ₯Ό μ‚¬μš©ν•˜μ—¬ 이λ₯Ό 동기화할 수 μžˆμŠ΅λ‹ˆλ‹€.

Using setTimeout when the modal closes might cause a potential issue where the Portal is removed before the animation completes. It's recommended to ensure the animation is complete before calling setVisible(false). You could use CSS animation events to synchronize this.

}
}, [isOpen]);

useEffect(() => {
document.body.style.overflow = isOpen ? 'hidden' : '';
return () => (document.body.style.overflow = '');
Expand All @@ -32,20 +44,24 @@ const Modal = ({
return () => document.removeEventListener('keydown', handleKeyDown);
}, [isOpen, onClose]);

if (!isOpen) {
// μ™„μ „νžˆ λ‹«νžŒ μƒνƒœμ—μ„œλŠ” Portal 자체 제거
if (!visible) {
return null;
}

return (
<ModalPortal>
<div
className="fixed inset-0 z-[1000] bg-black/50"
className={`fixed inset-0 z-[1000] bg-black/50 transition-opacity duration-200 ${
isOpen ? 'opacity-100' : 'opacity-0'
}`}
onClick={onClose}
aria-hidden="true"
/>

<div
className="tablet:w-[500px] mobile:w-[90vw] mobile:h-auto fixed left-1/2 top-1/2 z-[1001] flex h-[476px] max-h-[90vh] w-[600px] max-w-[90vw] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[16px] bg-white p-10 shadow-lg"
className={`tablet:w-[500px] mobile:w-[90vw] mobile:h-auto fixed left-1/2 top-1/2 z-[1001] flex h-[476px] max-h-[90vh] w-[600px] max-w-[90vw] -translate-x-1/2 -translate-y-1/2 flex-col overflow-hidden rounded-[16px] bg-white p-10 shadow-lg transition-transform duration-200 ${
isOpen ? 'scale-100' : 'scale-95 opacity-0'
}`}
role="dialog"
aria-modal="true"
onClick={(e) => e.stopPropagation()}>
Expand All @@ -55,11 +71,9 @@ const Modal = ({
date={createdAt}
profileImgUrl={profileImgUrl}
/>

<div className="flex-grow overflow-y-auto px-4 py-2">
<div className="flex-grow overflow-y-auto">
<PostContent htmlContent={contentHtml} font={font} />
</div>

<div className="flex justify-center pt-6">
<Button
theme="primary"
Expand Down
100 changes: 81 additions & 19 deletions src/components/common/modal/PostContent.jsx
Original file line number Diff line number Diff line change
@@ -1,32 +1,94 @@
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
import { useMemo, useEffect, useRef } from 'react';
import { FONT_CLASSES, QUILL_FONT_CLASSES } from '@/constants/fontMap';
import { SANITIZE_CONFIG } from '@/constants/sanitizeConfig';

const FONT_CLASSES = {
Pretendard: 'font-sans',
'Noto Sans': 'ff-noto',
λ‚˜λˆ”λͺ…μ‘°: 'ff-nanum-myeongjo',
'λ‚˜λˆ”μ†κΈ€μ”¨ μ†νŽΈμ§€μ²΄': 'ff-nanum-sonpyeonji',
};

const textStyle = (font) => {
const fontClass = FONT_CLASSES[font] || 'font-sans';
return `font-15-regular sm:font-18-regular text-gray-900 ${fontClass}`;

return `${fontClass} text-gray-900`;
};

/**
* React Quill μ—λ””ν„°μ˜ HTML 값을 μ•ˆμ „ν•˜κ²Œ 좜λ ₯ν•˜λŠ” μ»΄ν¬λ„ŒνŠΈ
*/
const PostContent = ({ htmlContent, font }) => {
const cleanHtml = useMemo(
() => DOMPurify.sanitize(htmlContent, SANITIZE_CONFIG),
[htmlContent]
);
const PostContent = ({ htmlContent, font, className, card }) => {
const contentRef = useRef(null);

const cleanHtml = useMemo(() => {
let sanitized = DOMPurify.sanitize(htmlContent, SANITIZE_CONFIG);

sanitized = sanitized.replace(/<span class="ql-ui".*?<\/span>/g, '');

return sanitized;
}, [htmlContent]);

useEffect(() => {
const container = contentRef.current;
if (!container) {
return;
}

container.innerHTML = cleanHtml;

const listItems = container.querySelectorAll('li');
listItems.forEach((li) => {
li.style.position = 'relative';

li.style.paddingLeft = '1.5em';
});

const lists = [
{
selector: 'li[data-list="ordered"]',
content: (index) => `${index + 1}. `,
},

{ selector: 'li[data-list="bullet"]', content: () => ' β€’ ' },

{
selector: 'li[data-list="unchecked"]',
content: () => '☐ ',
style: { color: '#1f2937' },
},

{
selector: 'li[data-list="checked"]',
content: () => 'β˜‘ ',
style: { fontWeight: 'bold', color: 'rgb(31, 41, 55)' },
},
];

lists.forEach(({ selector, style }) => {
const items = container.querySelectorAll(selector);
items.forEach((li) => {
if (li.querySelector('.ql-ui')) {
return;
}

const ui = document.createElement('span');
ui.className = 'ql-ui';

if (style) {
Object.assign(ui.style, style);
}

li.prepend(ui);
});
});
}, [cleanHtml]);

const quillFontClass = QUILL_FONT_CLASSES[font] || '';

return (
<div
className={`quill-content-view ${textStyle(font)}`}
dangerouslySetInnerHTML={{ __html: cleanHtml }}
ref={contentRef}
className={`ql-editor w-full ${textStyle(font)} ${quillFontClass} ${className}`}
style={{
padding: 0,
...(card
? {
overflow: 'hidden',
}
: {}),
}}
/>
);
};
Expand Down
34 changes: 6 additions & 28 deletions src/components/rolling-paper-list/message-card/MessageCard.jsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,10 @@
import { cva } from 'class-variance-authority';
import DOMPurify from 'dompurify';
import { useMemo } from 'react';
import Icons from '@/assets/icons/icons';
import Button from '@/components/common/button/Button';
import PostContent from '@/components/common/modal/PostContent';
import AuthorInfo from '@/components/rolling-paper-list/AuthorInfo';
import DateText from '@/components/rolling-paper-list/DateText';
import { SANITIZE_CONFIG_MESSAGECARD } from '@/constants/sanitizeConfig';
import { cn } from '@/utils/style';

const textStyle = cva(
'font-15-regular sm:font-18-regular line-clamp-2 text-gray-600 sm:line-clamp-3',
{
variants: {
font: {
Pretendard: 'font-sans',
'Noto Sans': 'ff-noto',
λ‚˜λˆ”λͺ…μ‘°: 'ff-nanum-myeongjo',
'λ‚˜λˆ”μ†κΈ€μ”¨ μ†νŽΈμ§€μ²΄': 'ff-nanum-sonpyeonji',
},
},
}
);

/** 둀링페이퍼 λͺ©λ‘μ—μ„œ μ‚¬μš©λ˜λŠ” λ©”μ‹œμ§€ μΉ΄λ“œ μ»΄ν¬λ„ŒνŠΈμž…λ‹ˆλ‹€.
* 클릭 μ‹œ ν•΄λ‹Ή λ©”μ‹œμ§€μ˜ 상세 λͺ¨λ‹¬μ„ λ„μ›λ‹ˆλ‹€.
* @param {object} props
Expand All @@ -48,11 +31,6 @@ const MessageCard = ({
edit = false,
onDelete,
}) => {
const sanitizedContent = useMemo(
() => DOMPurify.sanitize(content, SANITIZE_CONFIG_MESSAGECARD),
[content]
);

const handleKeyDown = (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
Expand Down Expand Up @@ -95,12 +73,12 @@ const MessageCard = ({
<Icons.DeletedIcon />
</Button>
)}

<div
className={cn(textStyle({ font }))}
dangerouslySetInnerHTML={{ __html: sanitizedContent }}
<PostContent
htmlContent={content}
className={'line-clamp-2 sm:line-clamp-3'}
font={font}
card
Comment on lines +76 to +80
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

PostContent μ»΄ν¬λ„ŒνŠΈλ₯Ό μ‚¬μš©ν•˜μ—¬ λ©”μ‹œμ§€ λ‚΄μš©μ„ ν‘œμ‹œν•˜λŠ” 것은 μ’‹μ§€λ§Œ, line-clamp 클래슀λ₯Ό PostContent λ‚΄λΆ€λ‘œ μ΄λ™μ‹œν‚€λŠ” 것을 κ³ λ €ν•΄ λ³΄μ„Έμš”. μ΄λ ‡κ²Œ ν•˜λ©΄ MessageCard μ»΄ν¬λ„ŒνŠΈκ°€ 더 깔끔해지고, PostContentκ°€ 자체적으둜 ν…μŠ€νŠΈ 쀄 truncation을 μ²˜λ¦¬ν•˜λ„λ‘ ν•  수 μžˆμŠ΅λ‹ˆλ‹€.

While using the PostContent component to display the message content is good, consider moving the line-clamp class inside PostContent. This would make the MessageCard component cleaner and allow PostContent to handle text truncation on its own.

/>

<DateText className="mt-auto" createdAt={createdAt} />
</div>
);
Expand Down
14 changes: 14 additions & 0 deletions src/constants/fontMap.js
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,17 @@ export const FONT_DISPLAY_NAMES = {
'nanum-myeongjo': 'λ‚˜λˆ”λͺ…μ‘°',
handletter: 'λ‚˜λˆ”μ†κΈ€μ”¨ μ†νŽΈμ§€μ²΄',
};

export const FONT_CLASSES = {
Pretendard: 'font-sans',
'Noto Sans': 'ff-noto',
λ‚˜λˆ”λͺ…μ‘°: 'ff-nanum-myeongjo',
'λ‚˜λˆ”μ†κΈ€μ”¨ μ†νŽΈμ§€μ²΄': 'ff-nanum-sonpyeonji',
};

export const QUILL_FONT_CLASSES = {
'noto-sans': 'ql-font-noto-sans',
pretendard: 'ql-font-pretendard',
'nanum-myeongjo': 'ql-font-nanum-myeongjo',
handletter: 'ql-font-handletter',
};
Loading