Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
6 changes: 6 additions & 0 deletions frontend/src/assets/toolbox-colon-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions frontend/src/assets/toolbox-line-icon.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
179 changes: 60 additions & 119 deletions frontend/src/components/Board/RichTextEditor.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ import toolboxFileIcon from '../../assets/toolbox-file-icon.svg';
import toolboxLeftIcon from '../../assets/toolbox-left-icon.svg';
import toolboxMidIcon from '../../assets/toolbox-mid-icon.svg';
import toolboxRightIcon from '../../assets/toolbox-right-icon.svg';
import toolboxLineIcon from '../../assets/toolbox-line-icon.svg';
import toolboxColonIcon from '../../assets/toolbox-colon-icon.svg';
import toolboxTextColorIcon from '../../assets/toolbox-textcolor-icon.svg';
import toolboxTextBgIcon from '../../assets/toolbox-textBackgroundColor-icon.svg';

Expand All @@ -41,17 +43,8 @@ const hasClipboardImage = async () => {
}
};

const FONT_FAMILY_OPTIONS = [
{ label: '기본', value: '' },
{ label: 'Pretendard', value: 'Pretendard' },
{ label: 'Noto Sans KR', value: 'Noto Sans KR' },
{ label: 'Apple SD Gothic Neo', value: 'Apple SD Gothic Neo' },
{ label: 'Arial', value: 'Arial' },
{ label: 'Georgia', value: 'Georgia' },
{ label: 'Courier New', value: 'Courier New' },
];

const FONT_SIZE_OPTIONS = [12, 14, 16, 18, 20, 24, 28, 32, 40];
const DEFAULT_FONT_SIZE = 15;
const FONT_SIZE_OPTIONS = [12, 14, 15, 16, 18, 20, 24, 28, 32, 40];
Comment thread
sangkyu39 marked this conversation as resolved.

const FontFamily = Extension.create({
name: 'fontFamily',
Expand Down Expand Up @@ -179,33 +172,6 @@ const ResizableImageNodeView = (props) => {
};

const onUp = () => {
// final sync: ensure attributes persisted in the ProseMirror document
try {
const imageEl = imageRef.current;
if (imageEl) {
const rectFinal = imageEl.getBoundingClientRect();
const finalWidth = Math.max(MIN_IMAGE_WIDTH, Math.round(rectFinal.width));
const finalHeight = Math.max(MIN_IMAGE_HEIGHT, Math.round(rectFinal.height));
const attrs = {};
if (isHorizontalOnly) attrs.width = `${finalWidth}px`;
else if (isVerticalOnly) attrs.height = `${finalHeight}px`;
else attrs.width = `${finalWidth}px`, attrs.height = `${finalHeight}px`;

// update via provided updateAttributes (NodeView) and editor command to ensure persistence
try {
updateAttributes?.(attrs);
} catch {}

try {
if (editor && editor.chain) {
editor.chain().focus().updateAttributes('image', attrs).run();
}
} catch {}
}
} catch (e) {
// ignore
}

stopResizing();
};

Expand Down Expand Up @@ -587,7 +553,7 @@ const RichTextEditor = ({

// We'll handle both images and other files by inserting them into the editor content.
// Prevent the default only when we actually handle insertion here.
let willHandle = imageFiles.length > 0 || otherFiles.length > 0;
const willHandle = imageFiles.length > 0 || otherFiles.length > 0;
if (!willHandle) return false;

event.preventDefault();
Expand Down Expand Up @@ -725,8 +691,8 @@ const RichTextEditor = ({
insertFileLink(normalizedUrl || '', uploaded?.originalFilename || file.name);
}
}
} catch (e) {
console.error('파일 업로드 실패:', e);
} catch (error) {
console.error('파일 업로드 실패:', error);
} finally {
event.target.value = '';
}
Expand Down Expand Up @@ -754,37 +720,13 @@ const RichTextEditor = ({
insertFileLink(normalizedUrl || '', uploaded?.originalFilename || file.name);
}
}
} catch (e) {
console.error('비디오 업로드 실패:', e);
} catch (error) {
console.error('비디오 업로드 실패:', error);
} finally {
event.target.value = '';
}
};

const promptLink = () => {
if (!editor || !editable) return;

const previousUrl = editor.getAttributes('link').href || '';
const nextUrl = window.prompt('링크 주소를 입력하세요.', previousUrl);

if (nextUrl === null) return;

const trimmedUrl = nextUrl.trim();

if (!trimmedUrl) {
editor.chain().focus().extendMarkRange('link').unsetLink().run();
return;
}

editor.chain().focus().extendMarkRange('link').setLink({ href: trimmedUrl }).run();
};

const clearFormatting = () => {
if (!editor || !editable) return;

editor.chain().focus().unsetAllMarks().clearNodes().setParagraph().run();
};

const applyTextColor = (color) => {
if (!editor || !editable) return;

Expand All @@ -803,28 +745,40 @@ const RichTextEditor = ({
// prefer to unset Highlight, fallback to clearing textStyle background
try {
editor.chain().focus().unsetHighlight().run();
} catch {
} catch (error) {
console.error('배경색 해제 실패:', error);
try {
editor.chain().focus().setMark('textStyle', { backgroundColor: null }).run();
} catch {}
} catch (nestedError) {
console.error('배경색 해제 실패(textStyle):', nestedError);
}
}
return;
}

// prefer Highlight (supports multicolor), fallback to textStyle mark
try {
editor.chain().focus().toggleHighlight({ color }).run();
} catch {
} catch (error) {
console.error('배경색 적용 실패:', error);
try {
editor.chain().focus().setMark('textStyle', { backgroundColor: color }).run();
} catch {}
} catch (nestedError) {
console.error('배경색 적용 실패(textStyle):', nestedError);
}
}
};

const applyFontFamily = (fontFamily) => {
const insertDividerLine = () => {
if (!editor || !editable) return;

editor.chain().focus().setMark('textStyle', { fontFamily }).run();
editor.chain().focus().setHorizontalRule().run();
};

const insertQuoteBlock = () => {
if (!editor || !editable) return;

editor.chain().focus().toggleBlockquote().run();
};

const applyFontSize = (fontSize) => {
Expand All @@ -838,33 +792,7 @@ const RichTextEditor = ({
editor.chain().focus().setMark('textStyle', { fontSize: String(fontSize) }).run();
};

const applyImageWidth = (width) => {
if (!editor || !editable || !editor.isActive('image')) return;

const nextWidth = width ? String(width) : null;
editor.chain().focus().updateAttributes('image', { width: nextWidth }).run();
};

const applyImageAlignment = (align) => {
if (!editor || !editable || !editor.isActive('image')) return;

editor.chain().focus().updateAttributes('image', { align }).run();
};

const resetSelectedImageSize = () => {
if (!editor || !editable || !editor.isActive('image')) return;

editor.chain().focus().updateAttributes('image', { width: null }).run();
};

const deleteSelectedImage = () => {
if (!editor || !editable || !editor.isActive('image')) return;

editor.chain().focus().deleteSelection().run();
};

const currentImageAlignment = editor?.isActive('image') ? editor.getAttributes('image')?.align || DEFAULT_IMAGE_ALIGN : DEFAULT_IMAGE_ALIGN;
const currentImageWidth = editor?.isActive('image') ? editor.getAttributes('image')?.width || '' : '';
const currentFontSize = editor?.getAttributes('textStyle')?.fontSize || DEFAULT_FONT_SIZE;
Comment thread
sangkyu39 marked this conversation as resolved.

useEffect(() => {
if (!editor) return;
Expand All @@ -885,7 +813,9 @@ const RichTextEditor = ({
return () => {
try {
editor.off('selectionUpdate', onSelection);
} catch {}
} catch (error) {
console.error('selectionUpdate 해제 실패:', error);
}
};
}, [editor]);

Expand All @@ -909,8 +839,9 @@ const RichTextEditor = ({
<div className={styles.editorShell}>
<div className={styles.toolbar}>
<div className={styles.mediaToolbar}>
<button type="button" title="이미지 업로드" onClick={() => imageInputRef.current?.click()} disabled={!editable || isUploadingImage} className={styles.iconButton}>
<button type="button" title="이미지 업로드" onClick={() => imageInputRef.current?.click()} disabled={!editable || isUploadingImage} className={styles.iconTile}>
<img src={toolboxImgIcon} alt="이미지" className={styles.iconImg} />
<span className={styles.iconTileLabel}>사진</span>
</button>
<input
ref={imageInputRef}
Expand All @@ -921,8 +852,9 @@ const RichTextEditor = ({
onChange={handleImageInputChange}
/>

<button type="button" title="비디오 업로드" onClick={() => videoInputRef.current?.click()} disabled={!editable} className={styles.iconButton}>
<button type="button" title="비디오 업로드" onClick={() => videoInputRef.current?.click()} disabled={!editable} className={styles.iconTile}>
<img src={toolboxVideoIcon} alt="비디오" className={styles.iconImg} />
<span className={styles.iconTileLabel}>동영상</span>
</button>
<input
ref={videoInputRef}
Expand All @@ -933,8 +865,9 @@ const RichTextEditor = ({
onChange={handleVideoInputChange}
/>

<button type="button" title="파일 업로드" onClick={() => fileInputRef.current?.click()} disabled={!editable} className={styles.iconButton}>
<button type="button" title="파일 업로드" onClick={() => fileInputRef.current?.click()} disabled={!editable} className={styles.iconTile}>
<img src={toolboxFileIcon} alt="파일" className={styles.iconImg} />
<span className={styles.iconTileLabel}>파일</span>
</button>
<input
ref={fileInputRef}
Expand All @@ -943,15 +876,25 @@ const RichTextEditor = ({
className={styles.hiddenFileInput}
onChange={handleFileInputChange}
/>

<button type="button" title="구분선 삽입" onClick={insertDividerLine} disabled={!editable} className={styles.iconTile}>
<img src={toolboxLineIcon} alt="구분선" className={styles.iconImg} />
<span className={styles.iconTileLabel}>구분선</span>
</button>

<button type="button" title="인용구 삽입" onClick={insertQuoteBlock} disabled={!editable} className={styles.iconTile}>
<img src={toolboxColonIcon} alt="인용구" className={styles.iconImg} />
<span className={styles.iconTileLabel}>인용구</span>
</button>
</div>

<div className={styles.formatToolbar}>
<select
className={styles.toolbarSelect}
value={FONT_SIZE_OPTIONS.includes(Number(editor.getAttributes('textStyle')?.fontSize)) ? Number(editor.getAttributes('textStyle')?.fontSize) : ''}
onChange={(event) => applyFontSize(event.target.value ? Number(event.target.value) : null)}
value={FONT_SIZE_OPTIONS.includes(Number(currentFontSize)) ? Number(currentFontSize) : DEFAULT_FONT_SIZE}
onChange={(event) => applyFontSize(event.target.value ? Number(event.target.value) : DEFAULT_FONT_SIZE)}
aria-label="글씨 크기 선택"
>
<option value="">크기</option>
{FONT_SIZE_OPTIONS.map((size) => (
<option key={size} value={size}>
{size}px
Expand All @@ -973,16 +916,16 @@ const RichTextEditor = ({
</button>

<div className={styles.colorGroup}>
<label className={styles.colorLabel} title="글자 색상 선택">
<img src={toolboxTextColorIcon} alt="text color" className={styles.colorAsset} />
<input
type="color"
className={styles.colorInput}
value={editor?.getAttributes('textStyle')?.color || '#222222'}
onChange={(e) => applyTextColor(e.target.value)}
aria-label="글자 색상 선택"
/>
</label>
<label className={styles.colorLabel} title="글자 색상 선택">
<img src={toolboxTextColorIcon} alt="text color" className={styles.colorAsset} />
<input
type="color"
className={styles.colorInput}
value={editor?.getAttributes('textStyle')?.color || '#222222'}
onChange={(e) => applyTextColor(e.target.value)}
aria-label="글자 색상 선택"
/>
</label>

<label className={styles.colorLabel} title="글자 배경색 선택">
<img src={toolboxTextBgIcon} alt="text background" className={styles.colorAsset} />
Expand All @@ -994,8 +937,6 @@ const RichTextEditor = ({
aria-label="글자 배경색 선택"
/>
</label>


</div>

<button type="button" onClick={() => editor.chain().focus().setTextAlign('left').run()} className={editor.isActive({ textAlign: 'left' }) ? styles.activeButton : ''} aria-label="왼쪽 정렬">
Expand Down
25 changes: 25 additions & 0 deletions frontend/src/components/Board/RichTextEditor.module.css
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}

.formatToolbar {
Expand Down Expand Up @@ -56,6 +57,26 @@
padding: 0;
}

.iconTile {
width: 58px !important;
height: 56px !important;
display: inline-flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 4px;
padding: 0 6px !important;
border-radius: 12px !important;
}

.iconTileLabel {
font-size: 11px;
line-height: 1;
color: #6b7280;
font-weight: 600;
letter-spacing: -0.02em;
}

.alignIcon {
display: inline-flex;
flex-direction: column;
Expand Down Expand Up @@ -415,3 +436,7 @@
padding-top: 8px;
padding-bottom: 8px;
}

.formatToolbar .toolbarSelect:first-child {
min-width: 126px;
}