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
Copy Markdown
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
Copy Markdown
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