Never give up

React - html to doc (feat.quill2) 본문

WEB

React - html to doc (feat.quill2)

대기만성 개발자 2022. 11. 15. 12:21
반응형

이전에 html을 pdf로 만드는 간단한 예제는 해봤는데

(https://devmemory.tistory.com/98)

 

수정 가능한 doc file을 만들어야 될 필요가 있어서 한번 만들어 봤습니다

 

App.tsx

const Main = () => {
  let text: string

  const downloadDoc = (e: React.MouseEvent<HTMLElement>, isDownload: boolean) => {
    e.preventDefault()

    if (text === undefined) {
      const quill = document.querySelector('#div_quill > .ql-editor')

      text = `${quill?.innerHTML}`
    }


    const doc = makeDoc(text, 'test')
    if (isDownload) {
      doc.download()
    } else {
      doc.getFile().then((file) => {
        console.log({ file })
      })
    }
  }

  const btnStyles = defaultStyles;
  btnStyles.backgroundColor = 'white';
  btnStyles.color = 'black';
  btnStyles.border = '1px solid grey'

  return (
    <div className={styles.div_main}>
      <div>
        <QuillComponent onChange={(value: string) => text = value}>
          <TestComponent />
        </QuillComponent>
        <div className={styles.div_btn}>
          <CommonBtn styles={btnStyles} onClick={(e) => downloadDoc(e, true)}>다운로드</CommonBtn>
          <CommonBtn styles={btnStyles} onClick={(e) => downloadDoc(e, false)}>파일 확인</CommonBtn>
        </div>
      </div>
    </div>
  )
}

export default Main

 

 

뷰는 간단하게 quill, test componenet 그리고 다운로드용 버튼 이렇게 구현을 해놨고

 

quill의 데이터가 변경될때마다 콜백으로 text를 가져옵니다

(해당 예제에서는 querySelector로 가져오는 부분으로만 처리해도 됩니다)

 

그리고 가져온 데이터를 makeDoc 클로져 함수를 이용해서 다운로드를 합니다

 

make_doc.ts

const makeDoc = (htmlText: string, fileName: string) => {
    const makeDataUrl = () => {
        const html =
            `
<html xmlns:o='urn:schemas-microsoft-com:office:office' xmlns:w='urn:schemas-microsoft-com:office:word'
>
    <head>
    <meta charset='utf-8'>
    <style>
        table {
            width: 100%;
            border: 1px solid black;
            border-collapse: collapse;
        }

        table th, table td {
            padding: 8px;
            border: 1px solid black;
        }
    </style>
    </head>
    <body>
        ${htmlText}
    </body>
</html>
`;

        const url =
            "data:application/vnd.ms-word;charset=utf-8," +
            encodeURIComponent(html);

        return url
    }

    const url = makeDataUrl()

    const getFile = async () => {
        const res = await fetch(url)

        const blob = await res.blob()

        return new File([blob], getFileName())
    }

    const download = () => {
        const a = document.createElement('a')
        a.style.display = 'none'

        a.download = getFileName()
        a.href = url
        a.click()
        a.remove()
    }

    const getFileName = () => {
        return fileName.endsWith('.doc') ? fileName : `${fileName}.doc`
    }

    return {
        getFile,
        download
    }
};

export default makeDoc

html scheme부분 보면 조금 독특한 부분이 보일텐데

 

html을 ms office XML 형태로 사용하는 attributes인거 같습니다

(링크 : https://stackoverflow.com/questions/27142065/what-is-meaning-of-xmlnsv-urnschemas-microsoft-com-in-html5)

 

추가로 테이블 부분 스타일을 지정해줬는데, 이 부분이 정의가 안되어 있으면

 

테이블 형태로 저장은 되는데 border가 보이지 않습니다

 

해당 부분을 data url로 변환후 a tag를 이용해서 다운로드 처리를 합니다

 

주의할 점은 확장자를 .doc으로 설정하지 않으면 내용에 html이 들어가있는 다른 형식의 파일이 나옵니다

 

quill_componenet.jsx

const QuillComponent = ({ children, onChange }) => {
    let quillEditor

    useEffect(() => {
        initQuill()

        return () => {
            quillEditor?.off("text-change", textChangeListener);
        }
    }, [])

    const initQuill = async () => {
        const { default: Quill } = await import("quill2");

        const color = Quill.import("attributors/class/color");
        const fontSize = Quill.import("attributors/style/size");
        const align = Quill.import("attributors/style/align");
        const fontStyle = Quill.import("attributors/class/font");

        // fontStyle.whitelist = ["Arial", "Helvetica", "sans-serif"];

        Quill.register(color, true);
        Quill.register(fontSize, true);
        Quill.register(align, true);
        Quill.register(fontStyle, true);

        const editor = document.getElementById('div_quill')

        quillEditor = new Quill(editor, {
            modules: {
                toolbar: getToolbarOptions(),
            },
            theme: "snow",
            placeholder: "내용을 입력해주세요...",
        });


        quillEditor.on("text-change", textChangeListener);
    }

    const getToolbarOptions = () => {
        let toolbarOptions = [
            [
                "image",
                "video",
                "blockquote",
                "link",
                { header: [1, 2, 3, 4, 5, 6, false] },
            ],
            [{ table: "append" }],
            [{ align: [] }, { indent: "-1" }, { indent: "+1" }],
            ["bold", "italic", "underline", "strike"],
            [{ list: "ordered" }, { list: "bullet" }],
            [{ color: [] }, { background: [] }],
        ];

        return toolbarOptions;
    };

    const textChangeListener = () => {
        let text = quillEditor.container.firstChild.innerHTML;
        if (text.includes("script")) {
            text = text.replaceAll("script", "");
            alert("script는 사용하실 수 없습니다.");
            return;
        } else if (text.includes("http")) {
            text = text.replaceAll("http", "");
            alert("http는 사용하실 수 없습니다.");
            return;
        }

        onChange(text)
    };

    return (
        <div id='div_quill'>{children}</div>
    )
}

export default QuillComponent

아쉽게도 quill2는 typescript 지원이 안되어서 jsx로 만들어봤습니다

 

test_componenet.tsx

const TestComponent = () => {
    return (
        <div>
            <div style={{ 'textAlign': 'center' }}>test</div>
            <p style={{ 'fontWeight': 'bold' }}>thead is not working in quill2</p>
            <table>
                <thead>
                    <tr>
                        <th>h1</th>
                        <th>h2</th>
                    </tr>
                </thead>
                <tbody>
                    <tr>
                        <td>d1</td>
                        <td>d2</td>
                    </tr>
                </tbody>
            </table>
            <br />
            <p style={{ 'fontWeight': 'bold' }}>only tbody is working in quill2</p>
            <table>
                <tbody>
                    <tr>
                        <td>h1</td>
                        <td>22</td>
                    </tr>
                    <tr>
                        <td>d1</td>
                        <td>d2</td>
                    </tr>
                </tbody>
            </table>
        </div>
    )
}

export default TestComponent

텍스트 편집기에 사용할 테스트 컴포넌트입니다

 

common_btn.tsx

type BtnProps = {
  children: JSX.Element | string,
  onClick?: React.MouseEventHandler<HTMLButtonElement> | undefined
  styles?: any,
  disabled?: boolean
}

export const defaultStyles = {
  backgroundColor: '#64b5f6',
  color: 'white',
  width: "120px",
  height: "40px",
  margin: 'unset',
  border: 'unset'
}

const CommonBtn: React.FC<BtnProps> = ({ children, onClick, styles = defaultStyles, disabled = false }) => {
  return (
    <button className='btn_common' onClick={onClick} style={{
      '--background-color': styles.backgroundColor,
      '--color': styles.color,
      '--width': styles.width,
      '--height': styles.height,
      '--margin': styles.margin,
      '--border': styles.border
    } as React.CSSProperties} disabled={disabled}>{children}</button>
  )
}

export default CommonBtn
.btn_common {
    background-color: var(--background-color);
    color: var(--color);
    width: var(--width);
    height: var(--height);
    margin: var(--margin);
    border: var(--border);
    border-radius: 4px;
    display: flex;
    justify-content: center;
    align-items: center;
}

.btn_common:hover {
    animation: btn 0.8s ease forwards;
}

@keyframes btn {
    from {
        outline: grey solid;
        outline-offset: 10px;
        transform: scale(1);
    }

    to {
        outline: transparent solid;
        outline-offset: 0px;
        transform: scale(1.05);
        box-shadow: 0 0 5px grey;
    }
}

svelte에서 사용하던 방식과 비슷하게 만들어 봤는데

 

svelte에 비해 조금 더 귀찮은 부분이 있는거 같습니다

 

동일한부분을 svelte로 만들어 보면

<script>
    export let width = "120px";
    export let height = "40px";
    export let margin = "0";
    export let backgroundColor = "var(--point-navy)";
    export let color = "white";
    export let disabled = false;
    export let border = 'unset'
    export let onClick;
</script>

<button
    class="btn_custom"
    type="button"
    style="--width: {width}; --height: {height}; --margin: {margin}; --background-color: {backgroundColor}; --color: {color}; --border: {border};"
    {disabled}
    on:click|preventDefault={onClick}
>
    <slot />
</button>

<style>
    .btn_common {
        background-color: var(--background-color);
        color: var(--color);
        width: var(--width);
        height: var(--height);
        margin: var(--margin);
        border: var(--border);
        border-radius: 4px;
        display: flex;
        justify-content: center;
        align-items: center;
    }

    .btn_common:hover {
        animation: btn 0.8s ease forwards;
    }

    @keyframes btn {
        from {
            outline: grey solid;
            outline-offset: 10px;
            transform: scale(1);
        }

        to {
            outline: transparent solid;
            outline-offset: 0px;
            transform: scale(1.05);
            box-shadow: 0 0 5px grey;
        }
    }
</style>

js라 조금 더 간단한것도 있지만

 

한 파일에 모든 부분이 나와서 조금 더 가독성도 좋고, boilerplate가 없어서 훨씬 편합니다

 

마지막으로 결과 부분을 보면 다음과 같습니다

< 웹 화면과 다운받은 doc파일 >

일부 스타일링은 작동이 안될 가능성이 있고, 테스트를 조금 해봐야될거 같습니다

 

추가로 이미지 파일 적용시에는 base64로 처리를 해야됩니다

 

전체 소스코드 링크 : https://github.com/devmemory/react_make_doc

반응형
Comments