카테고리 없음

[WebOrder] Toast UI Editor, Viewer 으로 공지사항 게시판 구현

cha430 2025. 10. 27. 14:47

 

나는 Vue3를 쓰기 때문에

구글링하다가 <Editor /> 형식으로 사용하는 것들은 적용할 수 없다고 한다. ^^..

ref 써야하고 ..

 

npm install @toast-ui/editor

 

이거.. /뒤에 vue까지 쓰는 건 vue2방식이니 주의 .....

 

import Editor from '@toast-ui/editor';
import '@toast-ui/editor/dist/toastui-editor.css';

 

css도 같이 npm install 하고 ... import 해주고 ..

 

 

 

참고한 블로그

https://blog.gizmo80.com

 

[Vue3]tui-editor vue3 wrapper

2025-10-15 추가.아래 Options API로 작성된 tui-editor wrapper를 이번에 사용할 일이 있어서 Composition로 바꿔 보았다.아래 소스를 사용하기 위해서는 아래 패키지를 먼저 설치 해야 된다. 뭐 또 언젠간 쓸

blog.gizmo80.com

 

 

어 제 는 ~ 분명히 부모 컴포넌트에서 open 함수 여는 코드로 안돌아갔는데 .^^. 오늘 하니까 갑자기 또 됨 ㅎ.ㅠ 

watch, nextTick 안쓰고 구현하기로 변경 ...

 

 

 

 

TuiEditor 컴포넌트 따로 빼서 사용 은 여기

 

 

 

watch, nextTick 없이 구현

<부모>

<NoticeCustsEditor ref="custsEditorDialogRef"/>

const custsEditorDialogRef = ref(null);

// 거래처 에디터 열기
const custsEditorOpen = () => {
    custsEditorDialogRef.value.open("띄울 내용 넘겨야함")
}

 

 

<자식>

 

<template>
    <!-- 공지사항 -->
    <v-dialog v-model="localValue" max-width="800">
        <v-card>
            <v-card-title>거래처 공지사항</v-card-title>
            <v-card-text>
                <v-autocomplete
                    label="거래처"
                    multiple
                    density="compact"
                    variant="outlined"
                    clearable
                    keydown.enter=""
                    style="width:400px;">
                </v-autocomplete>
                <div class="d-flex align-center" style="gap: 8px;">
                    <v-text-field
                        v-model="title"
                        label="제목"
                        density="compact"
                        variant="outlined"
                        clearable
                        keydown.enter="">
                    </v-text-field>
                    <div class="d-flex align-center" style="flex-shrink: 0; gap: 8px;">
                        <input type="file" multiple ref="fileInput" @change="onFileChange" style="display: none;" />
                        <v-btn
                            class = "mb-6"
                            color="secondary"
                            density="default"
                            @click="triggerFileInput">
                            파일 선택
                        </v-btn>
                        <span class = "mb-6">{{selectedFileName || '선택된 파일 없음'}}</span>
                    </div>
                </div>
                <div ref="tuiEditor" style="height:500px;"></div>
            </v-card-text>
            <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn color="primary" text @click="close">취소</v-btn>
                <v-btn color="primary" text @click="close">등록</v-btn>
            </v-card-actions>
        </v-card>
    </v-dialog>
</template>


<script setup>
import "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css";
import "tui-color-picker/dist/tui-color-picker.css";
import "@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css";
import '@toast-ui/editor/dist/toastui-editor.css';
import '@toast-ui/editor/dist/toastui-editor-viewer.css';
import "prismjs/themes/prism.css";

import colorSyntax from "@toast-ui/editor-plugin-color-syntax";
import Prism from "prismjs";
import "prismjs/components/prism-c";
import "prismjs/components/prism-cpp";
import "prismjs/components/prism-java";
import "prismjs/components/prism-python";
import "@toast-ui/editor/dist/i18n/ko-kr";
import { Editor } from "@toast-ui/editor";
import codeSyntaxHighlight from "@toast-ui/editor-plugin-code-syntax-highlight";
import { ref } from "vue";
import { consoleLog } from "@/assets/js/common/common.js";
import {_t} from "@/assets/js/common/common.js";

const emit = defineEmits(["update:modelValue"]);

const props = defineProps({
    modelValue: Boolean,
    content: { type: String, default: "" },
});

const localValue = ref(false);
const tuiEditor = ref(null);
let editor = null;
let editorText = ref("");

const title = ref("");
const fileInput = ref(null);
const selectedFileName = ref("");

// 파일 선택
const triggerFileInput = () => {
    fileInput.value.click();
};

// 파일 변경
const onFileChange = (event) => {
    const files = event.target.files;
    if (files.length > 0) {
        if (files.length === 1) {
            selectedFileName.value = files[0].name;
        } else {
            selectedFileName.value = `파일 ${files.length}개 선택`;
        }
    } else {
        selectedFileName.value = '';
    }
};

// Editor 생성
const setEditor = (content) => {
    if(editor) { 
        editor.destroy();
        editor = null;
    }
    
    editor = new Editor.factory({
        el: tuiEditor.value,
        height: "500px",
        viewer: false,
        toolbarItems: [
            ["heading", "bold", "italic", "strike"],
            ["hr", "quote"],
            ["ul", "ol", "task", "indent", "outdent"],
            ["table", "image", "link"],
            ["code", "codeblock"],
            ["scrollSync"],
        ],
        plugins: [colorSyntax, [codeSyntaxHighlight, { highlighter: Prism }]],
        initialValue: content || "",
        initialEditType: "wysiwyg",
        language: "ko-KR",
        events: {
            change: onChangeEditor,
        },
    })
};

// 내용 변경 시 부모로 emit
const onChangeEditor = () => {
    editorText.value = editor.getHTML();
    emit("update:modelValue", editorText.value);
};

const open = (content = "") => {
    localValue.value = true;
    consoleLog("공지 open()", content);

    // DOM 렌더링 직후 editor 초기화
    setTimeout(() => {
        if (tuiEditor.value) {
            setEditor(content);
        }
    }, 0);
};

const close = () => {
    localValue.value = false;
    if (editor) {
        editor.destroy();
        editor = null;
    }
};

const save = () => {
    console.log("NoticeEditor 저장:", editorText.value);
    close();
};

defineExpose({
    open,
    close,
    getContents: () => editorText.value,
});
</script>

 

 


watch, nextTick 써서 구현

 

<부모>

<NoticeCustsEditor ref="custsEditorDialogRef" v-model="custsEditorDialog" />

const custsEditorDialog = ref(false);

// 거래처 에디터 열기
const custsEditorOpen = () => {
    custsEditorDialog.value = true;
}

 

<자식>

<template>
    <!-- 공지사항 -->
    <v-dialog v-model="localValue" max-width="800">
        <v-card>
            <v-card-title>
                전체 공지사항
            </v-card-title>
            <v-card-text>
                <div class="d-flex align-center mb-4" style="gap: 8px;">
                    <v-text-field
                        label="제목"
                        density="compact"
                        variant="outlined"
                        clearable
                        keydown.enter="">
                    </v-text-field>
                    <div class="d-flex align-center" style="flex-shrink: 0; gap: 8px;">
                        <input type="file" multiple ref="fileInput" @change="onFileChange" style="display: none;" />
                        <v-btn
                            class = "mb-6"
                            color="secondary"
                            density="default"
                            @click="triggerFileInput">
                            파일 선택
                        </v-btn>
                        <span class = "mb-6">{{selectedFileName || '선택된 파일 없음'}}</span>
                    </div>
                </div>
                <div ref="tuiEditor" style="height:500px;"></div>
            </v-card-text>
            <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn color="primary" text @click="close">취소</v-btn>
                <v-btn color="primary" text @click="close">수정</v-btn>
                <v-btn color="primary" text @click="close">등록</v-btn>
            </v-card-actions>
        </v-card>
    </v-dialog>
</template>


<script setup>
import { ref, watch, nextTick } from "vue";
import Editor from "@toast-ui/editor";
import "@toast-ui/editor/dist/toastui-editor.css";
import "@toast-ui/editor/dist/toastui-editor-viewer.css";
import {_t} from "@/assets/js/common/common.js";

const props = defineProps({
    modelValue: Boolean,
    content: { type: String, default: "" },
});

const emit = defineEmits(["update:modelValue"]);

const tuiEditor = ref(null);
let editorInstance = null;

const localValue = ref(props.modelValue);

const fileInput = ref(null);
const selectedFileName = ref('');

const triggerFileInput = () => {
    fileInput.value.click();
};

const onFileChange = (event) => {
    const files = event.target.files;
    if (files.length > 0) {
        if (files.length === 1) {
            selectedFileName.value = files[0].name;
        } else {
            selectedFileName.value = `파일 ${files.length}개 선택`;
        }
    } else {
        selectedFileName.value = '';
    }
};




// props -> local 동기화
watch(
    () => props.modelValue,
    (val) => {
        localValue.value = val;

        if (val) {
            nextTick(() => {
                if (tuiEditor.value && !editorInstance) {
                    editorInstance = new Editor({
                        el: tuiEditor.value,
                        height: "500px",
                        initialEditType: "wysiwyg", // "markdown"
                        previewStyle: "vertical",
                        initialValue: props.content,
                    });
                } else if (editorInstance) {
                    editorInstance.setHTML(props.content);
                }
            });
        }
        else {
            if (editorInstance) {
                // editor.destroy()를 호출하여 메모리 해제 및 DOM 정리
                editorInstance.destroy();
                editorInstance = null; // 인스턴스 레퍼런스 초기화
            }
        }
    }
);


const close = () => {
    // 닫기 버튼을 누르면 watch가 val: false를 감지하고 destroy 로직을 실행
    localValue.value = false;
    emit("update:modelValue", false);
};

const getContents = () => editorInstance ? editorInstance.getHTML() : "";

defineExpose({
    open: (content) => {
        localValue.value = true;
    },
    getContents,
});
</script>


<style scoped>

</style>

 

 

 

 

 

 

 

 

 


 

 

TuiEditor 컴포넌트 따로 빼서 사용

 

 

NoticeEditor.vue

<template>
    <!-- 전체 공지 에디터 -->
    <v-dialog v-model="isOpen" max-width="800">
        <v-card>
            <v-card-title>전체 공지사항 등록수정</v-card-title>
            <v-card-text>
                <div class="d-flex align-center mb-4" style="gap: 8px;">
                    <v-text-field
                        v-model="title"
                        label="제목"
                        density="compact"
                        variant="outlined"
                        clearable
                        keydown.enter="">
                    </v-text-field>

                    <div class="d-flex align-center" style="flex-shrink: 0; gap: 8px;">
                        <v-btn color="primary mb-6" @click="openFileDialog">
                            첨부파일
                        </v-btn>
                    </div>
                </div>
                <TuiEditor ref="editor" :content="contents" :isViewer="isViewer"/>
            </v-card-text>
            <v-card-actions>
                <v-spacer></v-spacer>
                <v-btn color="primary" text @click="close">닫기</v-btn>
                <v-btn v-if="!isViewer" color="primary" text @click="save">등록</v-btn>
            </v-card-actions>
        </v-card>
    </v-dialog>

    <AddFileDialog ref="addFileDialogRef" @file-saved="" />
</template>

<script setup>
import {ref} from "vue";
import {consoleLog} from "@/assets/js/common/common.js";
import AddFileDialog from "@/components/common/dialog/AddFileDialog.vue";
import TuiEditor from "@/components/common/editor/TuiEditor.vue";

const props = defineProps({
    content: { type: String, default: "" },
    isViewer: { type: Boolean, default: false },
});

const emit = defineEmits(["update:modelValue"]);

let isOpen = ref(false);  // dialog 상태

// AddFileDialog
let addFileDialogRef = ref();

// TuiEditor
let editor = ref();
let isViewer = ref(false);    // viewer, editor
let title = ref("")
let contents = ref("")

// AddFileDialog.vue 열기
const openFileDialog = () => {
    const fileSeq = 1;
    addFileDialogRef.value?.open(fileSeq);
}

// 열기
const open = (data = { title:"", isViewer: false, content:"" }) => {
    consoleLog("전체 공지 open");

    isOpen.value = true;
    title = data.title;
    isViewer = data.isViewer;
    contents = data.content;
};

// 닫기
const close = () => {
    isOpen.value = false;
};

// 저장
const save = () => {
    consoleLog("NoticeEditor 저장:", editor.value.getContents());
    emit("update:modelValue", editor.value.getContents());
    close();
};

// 수정
const doEditMode = () => {
    editor.value.isEditMode(false)

}

// const getData = (value) => {
//     consoleLog("getData", value)
// }

defineExpose({
    open
});
</script>


<style scoped>

</style>

 

 

 

 

TuiEditor.vue

<template>
    <div ref="tuiEditor"></div>
</template>


<script setup>
import "@toast-ui/editor-plugin-code-syntax-highlight/dist/toastui-editor-plugin-code-syntax-highlight.css";
import "tui-color-picker/dist/tui-color-picker.css";
import "@toast-ui/editor-plugin-color-syntax/dist/toastui-editor-plugin-color-syntax.css";
import '@toast-ui/editor/dist/toastui-editor.css';
import '@toast-ui/editor/dist/toastui-editor-viewer.css';
import "prismjs/themes/prism.css";
import colorSyntax from "@toast-ui/editor-plugin-color-syntax";
import Prism from "prismjs";
import "prismjs/components/prism-c";
import "prismjs/components/prism-cpp";
import "prismjs/components/prism-java";
import "prismjs/components/prism-python";
import "@toast-ui/editor/dist/i18n/ko-kr";
import codeSyntaxHighlight from "@toast-ui/editor-plugin-code-syntax-highlight";
import { ref, onMounted } from "vue";
import { consoleLog } from "@/assets/js/common/common.js";
import { Editor } from "@toast-ui/editor";

const emit = defineEmits(["update:modelValue"]);

const props = defineProps({
    content: {type: String, default: "" },
    isViewer: {type: Boolean, default: false},
})

let tuiEditor = ref();
let editorText = ref("");
let editor = null

// Editor 생성
const setEditor = (isViewer) => {
    if (editor) {
        editor.destroy();
        editor = null;
    }

    console.log("isViewer", isViewer);
    editor = new Editor.factory({
        el: tuiEditor.value,
        height: "500px",
        viewer: isViewer,
        toolbarItems: [
            ["heading", "bold", "italic", "strike"],
            ["hr", "quote"],
            ["ul", "ol", "task", "indent", "outdent"],
            ["table", "image", "link"],
            ["code", "codeblock"],
            ["scrollSync"],
        ],
        plugins: [colorSyntax, [codeSyntaxHighlight, {highlighter: Prism}]],
        initialValue: props.content || "",
        initialEditType: "wysiwyg",   // "markdown"
        language: "ko-KR",
        events: {
            change: onChangeEditor,
        },
    });
}

// 에디터 내용 변경 시 부모한테 emit
const onChangeEditor = () => {
    editorText.value = editor.getHTML()
    emit("update:modelValue", editorText.value)
}

// 에디터 객체 생성하는 초기화 함수.
const doInit = () => {
    setEditor(props.isViewer)
    console.log("doInit", props.isViewer);
}

const isEditMode = (isViewer) => {
    editor.destroy()
    setEditor(isViewer);
}

/**
 * 부모가 자식의 현재 콘텐츠 직접 가져오도록 하는 getter.
 * editorRef.value.getContents() 형태로 호출.
 */
const getContents = () => {
    return editorText.value
}


onMounted(() => {
    doInit()
})

defineExpose({
    isEditMode,
    getContents,
})




</script>


<style scoped>

</style>