feat: Ref 호환 Tiptap 편집 기능 확장

국가 방침과 임관 권유 편집기에 글꼴, 크기, 색상, 정렬, 구분선과 sam-image 업로드를 추가한다. Ref Chromium 비교와 HTML 정화, 업로드 경계를 회귀 테스트로 고정한다.
This commit is contained in:
2026-08-19 16:53:11 +00:00
parent 9d45c8dd4c
commit 45ac97c839
7 changed files with 535 additions and 37 deletions
@@ -1,18 +1,44 @@
<script setup lang="ts">
import { onBeforeUnmount, watch } from 'vue';
import { onBeforeUnmount, ref, watch } from 'vue';
import { EditorContent, useEditor } from '@tiptap/vue-3';
import StarterKit from '@tiptap/starter-kit';
import Underline from '@tiptap/extension-underline';
import Link from '@tiptap/extension-link';
import Image from '@tiptap/extension-image';
import TextAlign from '@tiptap/extension-text-align';
import { TextStyleKit } from '@tiptap/extension-text-style';
import { trpc } from '../../utils/trpc';
const props = withDefaults(defineProps<{ modelValue: string; maxLength?: number }>(), { maxLength: 16384 });
const props = withDefaults(
defineProps<{ modelValue: string; maxLength?: number; ariaLabel?: string }>(),
{ maxLength: 16384, ariaLabel: 'HTML 편집기' }
);
const emit = defineEmits<{ (event: 'update:modelValue', value: string): void }>();
const fontFamilies = [
{ label: 'Pretendard', value: 'Pretendard, sans-serif' },
{ label: '맑은 고딕', value: 'Malgun Gothic, sans-serif' },
{ label: '궁서', value: 'Gungsuh, serif' },
{ label: '돋움', value: 'Dotum, sans-serif' },
];
const fontSizes = ['8px', '10px', '12px', '14px', '18px', '22px', '28px', '36px', '48px', '72px'];
const fileInput = ref<HTMLInputElement | null>(null);
const uploadBusy = ref(false);
const uploadError = ref<string | null>(null);
const editor = useEditor({
content: props.modelValue,
extensions: [StarterKit, Underline, Link.configure({ openOnClick: false })],
extensions: [
StarterKit.configure({ link: { openOnClick: false } }),
Image.configure({ inline: false, allowBase64: false }),
TextAlign.configure({ types: ['heading', 'paragraph'], alignments: ['left', 'center', 'right'] }),
TextStyleKit,
],
editorProps: {
attributes: { class: 'legacy-html-editor__content', 'aria-label': 'HTML 편집기' },
attributes: {
class: 'legacy-html-editor__content',
role: 'textbox',
'aria-label': props.ariaLabel,
'aria-multiline': 'true',
},
},
onUpdate: ({ editor: instance }) => {
const html = instance.getHTML();
@@ -37,79 +63,341 @@ const setLink = () => {
else editor.value.chain().focus().extendMarkRange('link').setLink({ href: href.trim() }).run();
};
const setFontFamily = (event: Event) => {
const value = (event.target as HTMLSelectElement).value;
if (!editor.value) return;
if (value) editor.value.chain().focus().setFontFamily(value).run();
else editor.value.chain().focus().unsetFontFamily().run();
};
const setFontSize = (event: Event) => {
const value = (event.target as HTMLSelectElement).value;
if (!editor.value) return;
if (value) editor.value.chain().focus().setFontSize(value).run();
else editor.value.chain().focus().unsetFontSize().run();
};
const setColor = (event: Event, kind: 'foreground' | 'background') => {
const value = (event.target as HTMLInputElement).value;
if (kind === 'foreground') editor.value?.chain().focus().setColor(value).run();
else editor.value?.chain().focus().setBackgroundColor(value).run();
};
const clearColors = () => editor.value?.chain().focus().unsetColor().unsetBackgroundColor().run();
const readFileAsDataUrl = (file: File) =>
new Promise<string>((resolve, reject) => {
const reader = new FileReader();
reader.onload = () =>
typeof reader.result === 'string' ? resolve(reader.result) : reject(new Error('이미지를 읽을 수 없습니다.'));
reader.onerror = () => reject(new Error('이미지를 읽는 중 오류가 발생했습니다.'));
reader.readAsDataURL(file);
});
const uploadImage = async (event: Event) => {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file || uploadBusy.value) return;
uploadBusy.value = true;
uploadError.value = null;
try {
const dataUrl = await readFileAsDataUrl(file);
const result = await trpc.board.uploadImage.mutate({ dataUrl });
editor.value?.chain().focus().setImage({ src: result.url, alt: file.name }).run();
} catch (error) {
uploadError.value = error instanceof Error ? error.message : '이미지 업로드에 실패했습니다.';
} finally {
uploadBusy.value = false;
input.value = '';
}
};
onBeforeUnmount(() => editor.value?.destroy());
</script>
<template>
<div class="legacy-html-editor">
<div class="legacy-html-editor__toolbar" role="toolbar" aria-label="서식">
<button type="button" title="되돌리기" aria-label="되돌리기" @click="editor?.chain().focus().undo().run()">
</button>
<button type="button" title="재실행" aria-label="재실행" @click="editor?.chain().focus().redo().run()">
</button>
<button
type="button"
title="굵게"
aria-label="굵게"
:class="{ active: editor?.isActive('bold') }"
@click="editor?.chain().focus().toggleBold().run()"
>
<b>굵게</b>
<b>B</b>
</button>
<button
type="button"
title="기울임"
aria-label="기울임"
:class="{ active: editor?.isActive('italic') }"
@click="editor?.chain().focus().toggleItalic().run()"
>
<i>기울임</i>
<i>I</i>
</button>
<button
type="button"
title="밑줄"
aria-label="밑줄"
:class="{ active: editor?.isActive('underline') }"
@click="editor?.chain().focus().toggleUnderline().run()"
>
<u>밑줄</u>
<u>U</u>
</button>
<button
type="button"
title="취소선"
aria-label="취소선"
:class="{ active: editor?.isActive('strike') }"
@click="editor?.chain().focus().toggleStrike().run()"
>
<s>S</s>
</button>
<label class="legacy-html-editor__select">
<span class="legacy-html-editor__sr-only">글꼴</span>
<select aria-label="글꼴" @change="setFontFamily">
<option value="">글꼴</option>
<option v-for="font in fontFamilies" :key="font.value" :value="font.value" :style="{ fontFamily: font.value }">
{{ font.label }}
</option>
</select>
</label>
<label class="legacy-html-editor__select">
<span class="legacy-html-editor__sr-only">크기</span>
<select aria-label="글꼴 크기" @change="setFontSize">
<option value="">크기</option>
<option v-for="size in fontSizes" :key="size" :value="size" :style="{ fontSize: size }">{{ size }}</option>
</select>
</label>
<label class="legacy-html-editor__color" title="글자색">
<span class="legacy-html-editor__sr-only">글자색</span>
<input
type="color"
aria-label="글자색"
:value="editor?.getAttributes('textStyle').color ?? '#ffffff'"
@input="setColor($event, 'foreground')"
/>
</label>
<label class="legacy-html-editor__color" title="배경색">
<span class="legacy-html-editor__sr-only">배경색</span>
<input
type="color"
aria-label="배경색"
:value="editor?.getAttributes('textStyle').backgroundColor ?? '#000000'"
@input="setColor($event, 'background')"
/>
</label>
<button type="button" title="글자색과 배경색 지우기" aria-label="색상 지우기" @click="clearColors">
</button>
<button
type="button"
title="글머리 기호 목록"
aria-label="글머리 기호 목록"
:class="{ active: editor?.isActive('bulletList') }"
@click="editor?.chain().focus().toggleBulletList().run()"
>
목록
</button>
<button type="button" :class="{ active: editor?.isActive('link') }" @click="setLink">링크</button>
<button type="button" @click="editor?.chain().focus().unsetAllMarks().clearNodes().run()">
서식 지우기
<button
type="button"
title="번호 목록"
aria-label="번호 목록"
:class="{ active: editor?.isActive('orderedList') }"
@click="editor?.chain().focus().toggleOrderedList().run()"
>
1.
</button>
<button
type="button"
title="링크"
aria-label="링크"
:class="{ active: editor?.isActive('link') }"
@click="setLink"
>
</button>
<button
type="button"
title="왼쪽 정렬"
aria-label="왼쪽 정렬"
:class="{ active: editor?.isActive({ textAlign: 'left' }) }"
@click="editor?.chain().focus().setTextAlign('left').run()"
>
</button>
<button
type="button"
title="가운데 정렬"
aria-label="가운데 정렬"
:class="{ active: editor?.isActive({ textAlign: 'center' }) }"
@click="editor?.chain().focus().setTextAlign('center').run()"
>
</button>
<button
type="button"
title="오른쪽 정렬"
aria-label="오른쪽 정렬"
:class="{ active: editor?.isActive({ textAlign: 'right' }) }"
@click="editor?.chain().focus().setTextAlign('right').run()"
>
</button>
<button
type="button"
title="구분선"
aria-label="구분선"
@click="editor?.chain().focus().setHorizontalRule().run()"
>
</button>
<button
type="button"
:disabled="uploadBusy"
title="이미지 업로드"
aria-label="이미지"
@click="fileInput?.click()"
>
{{ uploadBusy ? '…' : '▧' }}
</button>
<input
ref="fileInput"
class="legacy-html-editor__file"
type="file"
accept=".jpg,.jpeg,.png,.gif,.webp,.avif"
aria-label="업로드할 이미지"
@change="uploadImage"
/>
<button
type="button"
title="서식 지우기"
aria-label="서식 지우기"
@click="editor?.chain().focus().unsetAllMarks().clearNodes().run()"
>
Tx
</button>
</div>
<EditorContent :editor="editor" />
<p v-if="uploadError" class="legacy-html-editor__error" role="alert">{{ uploadError }}</p>
</div>
</template>
<style scoped>
.legacy-html-editor {
border: 1px solid #777;
background: #fff;
color: #111;
border: 0;
background: transparent;
color: inherit;
}
.legacy-html-editor__toolbar {
display: flex;
flex-wrap: wrap;
gap: 2px;
border-bottom: 1px solid #aaa;
padding: 3px;
background: #ddd;
gap: 0;
border-bottom: 0;
padding: 0;
background: #303030;
}
.legacy-html-editor__toolbar button {
border: 1px solid #777;
border-radius: 2px;
padding: 2px 7px;
background: #f5f5f5;
color: #111;
.legacy-html-editor__toolbar button,
.legacy-html-editor__toolbar select {
box-sizing: border-box;
min-width: 37px;
height: 35px;
border: 1px solid transparent;
border-radius: 0;
padding: 5px 10px;
background: #303030;
color: #fff;
cursor: pointer;
}
.legacy-html-editor__toolbar button:hover,
.legacy-html-editor__toolbar select:hover,
.legacy-html-editor__toolbar button:focus-visible,
.legacy-html-editor__toolbar select:focus-visible {
border-color: #9dc8f0;
outline: 1px solid #9dc8f0;
background: #444;
}
.legacy-html-editor__toolbar button:disabled {
cursor: wait;
opacity: 0.65;
}
.legacy-html-editor__toolbar button.active {
background: #b9d4f0;
background: #555;
}
.legacy-html-editor__select,
.legacy-html-editor__color {
display: inline-flex;
align-items: center;
gap: 0;
color: inherit;
font-size: 12px;
}
.legacy-html-editor__select select {
min-width: 0;
max-width: 118px;
padding: 5px 6px;
}
.legacy-html-editor__color input {
box-sizing: border-box;
width: 42px;
height: 35px;
border: 1px solid transparent;
padding: 4px;
background: #303030;
cursor: pointer;
}
.legacy-html-editor__file {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
clip-path: inset(50%);
}
.legacy-html-editor__error {
margin: 0;
padding: 4px 6px;
color: #ff9b9b;
background: rgba(80, 0, 0, 0.45);
}
:deep(.legacy-html-editor__content) {
min-height: 110px;
padding: 6px;
min-height: 42px;
padding: 0;
outline: none;
overflow-wrap: anywhere;
background: transparent;
color: inherit;
}
.legacy-html-editor__sr-only {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
button[aria-label='왼쪽 정렬'] {
text-align: left;
}
button[aria-label='가운데 정렬'] {
text-align: center;
}
button[aria-label='오른쪽 정렬'] {
text-align: right;
}
@media (max-width: 500px) {
:deep(.legacy-html-editor__content) {
min-height: 21px;
}
}
:deep(.legacy-html-editor__content p) {
margin: 0 0 0.4em;
@@ -218,7 +218,7 @@ onMounted(() => void loadStratFinan());
</div>
<div class="notice-title">국가 방침 &amp; 임관 권유 메시지</div>
<section id="notice-form" class="message-form">
<section id="notice-form" class="message-form" :class="{ 'message-form--editing': editingNationMsg }">
<header class="green-header">
<span>국가 방침</span>
<span>
@@ -249,9 +249,18 @@ onMounted(() => void loadStratFinan());
</span>
</header>
<div v-if="!editingNationMsg" class="message-preview" v-html="nationMsg || '내용 없음'" />
<LegacyHtmlEditor v-else v-model="nationMsgDraft" :max-length="16384" />
<LegacyHtmlEditor
v-else
v-model="nationMsgDraft"
:max-length="16384"
aria-label="국가 방침"
/>
</section>
<section id="scout-message-form" class="message-form">
<section
id="scout-message-form"
class="message-form"
:class="{ 'message-form--editing': editingScoutMsg }"
>
<header class="green-header">
<span>임관 권유</span>
<span>
@@ -283,7 +292,12 @@ onMounted(() => void loadStratFinan());
</header>
<div class="scout-limit">870px x 200px를 넘어서는 내용은 표시되지 않습니다.</div>
<div v-if="!editingScoutMsg" class="message-preview scout-preview" v-html="scoutMsg || '내용 없음'" />
<LegacyHtmlEditor v-else v-model="scoutMsgDraft" :max-length="1000" />
<LegacyHtmlEditor
v-else
v-model="scoutMsgDraft"
:max-length="1000"
aria-label="임관 권유"
/>
</section>
<div class="finance-title">예산&amp;정책</div>
@@ -712,6 +726,11 @@ textarea {
height: 61.5px;
overflow: hidden;
}
#notice-form.message-form--editing,
#scout-message-form.message-form--editing {
height: auto;
overflow: visible;
}
.finance-grid {
height: 218.63px;
margin-bottom: 15.25px;