범용 발췌 모달

<span class="sv_member">린더</span>
린더 @frauroteschuhe
2026-10-09 00:01

라공에디션/아보카도 에디션/티스토리 등에서 사용하실 수 있는 발췌 모달을 배포합니다.

원하시는 곳에 편하게 넣어주시면 됩니다.

언제나 그러하듯 AI의 도움을 받았습니다.

도움이 되었다면 배포 트윗 인용으로 유키 화이트 화이팅!! 한번만 외쳐주세요.

코드 수정 및 재배포는 언제나 자유롭게. 돈만 거시지 마세요.

질문은 언제나 이쪽 URL입니다.


이 모달을 사용하시면 이러한 것을 만드실 수 있습니다.


「친애하는 화이트 양에게.」_발췌.png





필요 라이브러리

> 위치 : 들어가는 아무데나 괜찮습니다.

<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>


발췌 버튼

> 위치 : .post-view-container, #bo_v_con 등 컨테이너의 바깥. 

위를 추천합니다…… 사유 : 그것이 모달과 따로 관리하기 편함

<button type="button" id="excerpt-selection-btn">
    발췌
</button>


모달

> 위치 : .post-view-container, #bo_v_con 등 컨테이너의 바깥. 

모달은 </div> 아래에 넣는 것을 추천합니다. 사유 : 그것이 발췌 버튼과 따로 관리하기 편함.


<div
    id="excerpt-modal"
    aria-hidden="true"
    data-title="<?= htmlspecialchars($view['subject'], ENT_QUOTES) ?>"
>
    <div class="excerpt-modal-backdrop"></div>

    <div class="excerpt-modal-panel" role="dialog" aria-modal="true">
        <button
            type="button"
            class="excerpt-modal-close"
            aria-label="닫기"
        >
            ×
        </button>

        <div class="excerpt-modal-layout">
            <div class="excerpt-modal-controls">
                
                <div class="excerpt-control-group">
    <label for="excerpt-font-weight">글자 굵기</label>
    <select id="excerpt-font-weight">
        <option value="200">ExtraLight</option>
        <option value="300">Light</option>
        <option value="400" selected>Regular</option>
        <option value="600">SemiBold</option>
        <option value="700">Bold</option>
    </select>
</div>
                
                <div class="excerpt-control-group">
                    <label for="excerpt-font-size">글자 크기</label>
                    <div class="excerpt-range-row">
                        <input
                            type="range"
                            id="excerpt-font-size"
                            min="14"
                            max="36"
                            step="1"
                            value="18"
                        >
                        <span id="excerpt-font-size-value">18px</span>
                    </div>
                </div>

                <div class="excerpt-control-group">
                    <span class="excerpt-control-label">정렬</span>
                    <div class="excerpt-align-buttons">
                        <button type="button" class="excerpt-align-btn is-active" data-align="left">왼쪽</button>
                        <button type="button" class="excerpt-align-btn" data-align="center">가운데</button>
                        <button type="button" class="excerpt-align-btn" data-align="right">오른쪽</button>
                    </div>
                </div>

                <div class="excerpt-control-group">
                    <label for="excerpt-bg-color">배경색</label>
                    <input
                        type="color"
                        id="excerpt-bg-color"
                        value="#f7f7f7"
                    >
                </div>

                <div class="excerpt-control-group">
                    <label for="excerpt-text-color">글자색</label>
                    <input
                        type="color"
                        id="excerpt-text-color"
                        value="#222222"
                    >
                </div>

                <div class="excerpt-control-group">
                    <label for="excerpt-padding">안쪽 여백</label>
                    <div class="excerpt-range-row">
                        <input
                            type="range"
                            id="excerpt-padding"
                            min="30"
                            max="100"
                            step="2"
                            value="56"
                        >
                        <span id="excerpt-padding-value">56px</span>
                    </div>
                </div>
                
                <div class="excerpt-control-group">
                    <label for="excerpt-author-input">글 작성자</label>
                    <input
                        type="text"
                        id="excerpt-author-input"
                        placeholder="작성자명을 입력하세요"
                        autocomplete="off"
                    >
                </div>

                <div class="excerpt-control-group">
                    <label class="excerpt-check">
                        <input
                            type="checkbox"
                            id="excerpt-show-title"
                            checked
                        >
                        글 제목 표시
                    </label>
                </div>
                <div class="excerpt-control-group">
                    <label for="excerpt-title-input">글 제목</label>
                    <input
                        type="text"
                        id="excerpt-title-input"
                        placeholder="글 제목을 입력하세요"
                    >
                </div>
                <div class="excerpt-control-group">
    <button type="button" id="excerpt-save-btn">
        PNG로 저장
    </button>
</div>
            </div>

            <div class="excerpt-modal-preview-wrap">
                <div class="excerpt-modal-preview">
                    <div id="excerpt-card">
                        <div id="excerpt-modal-text"></div>
                        <div id="excerpt-modal-source"></div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>



CSS

> 위치 : 들어가는 어디든 괜찮습니다.

<style>태그를 이용해 view.skin.php 등에 직접 넣으실 경우 모달 위를 추천합니다. 

CSS 파일이라면 @font-face 때문에라도 맨 위에 넣으셔야 할 겁니다…

@font-face {
    font-family: 'MaruBuri';
    src: url('https://hangeul.pstatic.net/hangeul_static/webfont/MaruBuri/MaruBuri-ExtraLight.woff2') format('woff2');
    font-weight: 200;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: 'MaruBuri';
    src: url('https://hangeul.pstatic.net/hangeul_static/webfont/MaruBuri/MaruBuri-Light.woff2') format('woff2');
    font-weight: 300;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: 'MaruBuri';
    src: url('https://hangeul.pstatic.net/hangeul_static/webfont/MaruBuri/MaruBuri-Regular.woff2') format('woff2');
    font-weight: 400;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: 'MaruBuri';
    src: url('https://hangeul.pstatic.net/hangeul_static/webfont/MaruBuri/MaruBuri-SemiBold.woff2') format('woff2');
    font-weight: 600;
    font-style: normal;
    font-display: swap;
}

@font-face {
    font-family: 'MaruBuri';
    src: url('https://hangeul.pstatic.net/hangeul_static/webfont/MaruBuri/MaruBuri-Bold.woff2') format('woff2');
    font-weight: 700;
    font-style: normal;
    font-display: swap;
}

#excerpt-save-btn:disabled {
    opacity: .5;
    cursor: wait;
}

#excerpt-modal {
    position: fixed;
    inset: 0;
    z-index: 100000;

    display: none;
    align-items: center;
    justify-content: center;
}

#excerpt-modal.is-open {
    display: flex;
}

.excerpt-modal-backdrop {
    position: absolute;
    inset: 0;
    background: rgba(0, 0, 0, 0.45);
}

.excerpt-modal-panel {
    position: relative;
    z-index: 1;

    width: min(94vw, 1100px);
    max-height: 92vh;
    overflow-y: auto;

    box-sizing: border-box;
    padding: 50px 24px 24px;

    background: #fff;
    border-radius: 14px;
    box-shadow: 0 10px 40px rgba(0, 0, 0, 0.2);
}

.excerpt-modal-close {
    position: absolute;
    top: 12px;
    right: 14px;

    border: 0;
    background: transparent;

    font-size: 28px;
    line-height: 1;
    cursor: pointer;
}

.excerpt-modal-layout {
    display: grid;
    grid-template-columns: 300px minmax(0, 1fr);
    gap: 24px;
}

.excerpt-modal-controls {
    display: flex;
    flex-direction: column;
    gap: 18px;
}

.excerpt-control-group {
    display: flex;
    flex-direction: column;
    gap: 8px;
}

.excerpt-control-group label,
.excerpt-control-label {
    font-size: 14px;
    font-weight: 600;
    color: #222;
}

.excerpt-control-group select,
.excerpt-control-group input[type="color"],
.excerpt-control-group input[type="text"] {
    box-sizing: border-box;
    width: 100%;
    height: 40px;

    border: 1px solid #ddd;
    border-radius: 8px;
    background: #fff;
}

.excerpt-control-group input[type="text"] {
    padding: 0 12px;
    font-size: 14px;
}

.excerpt-control-group select {
    padding: 0 12px;
    font-size: 14px;
}

.excerpt-range-row {
    display: flex;
    align-items: center;
    gap: 10px;
}

.excerpt-range-row input[type="range"] {
    flex: 1;
}

.excerpt-range-row span {
    min-width: 48px;
    font-size: 14px;
    color: #555;
    text-align: right;
}

.excerpt-align-buttons {
    display: flex;
    gap: 8px;
}

.excerpt-align-btn {
    padding: 8px 12px;

    border: 1px solid #ddd;
    border-radius: 8px;
    background: #fff;

    font-size: 14px;
    cursor: pointer;
}

.excerpt-align-btn.is-active {
    border-color: #222;
    background: #222;
    color: #fff;
}

.excerpt-check {
    display: inline-flex;
    align-items: center;
    gap: 8px;

    font-size: 14px;
    font-weight: 400;
}

.excerpt-modal-preview-wrap {
    min-width: 0;
}

.excerpt-modal-preview {
    box-sizing: border-box;
    width: 100%;
    min-height: 640px;

    padding: 20px;

    display: flex;
    align-items: center;
    justify-content: center;

    background: #f2f2f2;
    border-radius: 12px;
}

#excerpt-save-btn {
    width: 100%;
    padding: 12px 16px;

    border: 0;
    border-radius: 8px;

    background: #222;
    color: #fff;

    font-size: 14px;
    cursor: pointer;
}

#excerpt-card {
    box-sizing: border-box;

    width: min(100%, 560px);
    min-height: 560px;
    font-family: 'MaruBuri', serif;

    padding: 56px;

    
    display: flex;
    flex-direction: column;
    justify-content: center;

    background: #f7f7f7;
    color: #222;

    border-radius: 0;
    box-shadow: 0 4px 18px rgba(0, 0, 0, 0.08);
}

#excerpt-modal-text {
    width: 100%;
    font-size: 18px;
    line-height: 1.8;
    text-align: left;
    word-break: keep-all;
    overflow-wrap: break-word;
    font-family: inherit;
}

#excerpt-modal-text p {
    margin: 0 0;
    text-indent: 1em;
}

#excerpt-modal-text p:last-child {
    margin-bottom: 0;
}

#excerpt-modal-text strong, #excerpt-card-export strong { font-weight: 700; }
#excerpt-modal-text em, #excerpt-card-export em { font-style: italic; }
#excerpt-modal-text u, #excerpt-card-export u { text-decoration: underline; }
#excerpt-modal-text s, #excerpt-card-export s { text-decoration: line-through; }


#excerpt-modal-source {
    margin-top: 26px;

    font-size: 14px;
    line-height: 1.6;
    opacity: 0.72;

    white-space: pre-wrap;
    word-break: keep-all;
    font-family: inherit;
}

@media (max-width: 900px) {
    .excerpt-modal-layout {
        grid-template-columns: 1fr;
    }

    .excerpt-modal-preview {
        min-height: auto;
    }

    #excerpt-card {
        min-height: 420px;
    }
}
    #excerpt-selection-btn {
    position: fixed;
    z-index: 99999;

    display: none;

    padding: 6px 10px;

    border: 0;
    border-radius: 6px;

    background: var(--primary-color);
    color: #fff;

    font-size: 13px;
    line-height: 1;

    cursor: pointer;

    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.18);
}

#excerpt-selection-btn.is-visible {
    display: block;
}


JS 스크립트

> 위치 : 따지자면 최하단을 추천드립니다.

> 커스텀 필요 부분 : const content = document.querySelector('#bo_v_con'); 의 #bo_v_con 을 사용하시는 스킨의 컨테이너로 바꿔주셔야 합니다.

#container_wr 으로 바꿔도 될 거라고 생각은 합니다만 제가 생각한 대로 움직여줄지는 잘 모르겠습니다.

<script>
document.addEventListener('DOMContentLoaded', function () {

    const content = document.querySelector('#bo_v_con');
    const excerptBtn = document.querySelector('#excerpt-selection-btn');
    const excerptModal = document.querySelector('#excerpt-modal');
const excerptModalText = document.querySelector('#excerpt-modal-text');
const excerptModalClose = document.querySelector('.excerpt-modal-close');
const excerptModalBackdrop = document.querySelector('.excerpt-modal-backdrop');
const excerptCard = document.querySelector('#excerpt-card');
const excerptModalSource = document.querySelector('#excerpt-modal-source');

const excerptFontSize = document.querySelector('#excerpt-font-size');
const excerptFontSizeValue = document.querySelector('#excerpt-font-size-value');

const excerptBgColor = document.querySelector('#excerpt-bg-color');
const excerptTextColor = document.querySelector('#excerpt-text-color');

const excerptPadding = document.querySelector('#excerpt-padding');
const excerptPaddingValue = document.querySelector('#excerpt-padding-value');

const excerptShowTitle = document.querySelector('#excerpt-show-title');
const excerptAlignButtons = document.querySelectorAll('.excerpt-align-btn');

const postTitle = excerptModal?.dataset.title || '';

const excerptAuthorInput = document.querySelector('#excerpt-author-input');
const excerptTitleInput = document.querySelector('#excerpt-title-input');

// 게시글 제목을 입력창의 기본값으로 설정
if (excerptTitleInput) {
    excerptTitleInput.value = postTitle;
}

const excerptFontWeight = document.querySelector('#excerpt-font-weight');
const excerptSaveBtn = document.querySelector('#excerpt-save-btn');


const EXPORT_WIDTH = 720;
const EXPORT_BASE_WIDTH = 560;
const EXPORT_SCALE = EXPORT_WIDTH / EXPORT_BASE_WIDTH;

let preparedPngBlob = null;
let exportRenderTimer = null;
let exportRenderVersion = 0;

const excerptStyleState = {
    fontWeight: 400,
    fontSize: 18,
    textAlign: 'left',
    backgroundColor: '#fff',
    textColor: '#000',
    padding: 56,
    showTitle: true,
};

    if (!content || !excerptBtn) {
        return;
    }

    let selectedText = '';
    let selectedRange = null;


    /**
     * 현재 선택 영역이 게시글 본문 내부인지 확인
     */
    function isSelectionInsideContent(selection) {

        if (!selection || selection.rangeCount === 0) {
            return false;
        }

        const range = selection.getRangeAt(0);

        const startNode = range.startContainer.nodeType === Node.TEXT_NODE
            ? range.startContainer.parentElement
            : range.startContainer;

        const endNode = range.endContainer.nodeType === Node.TEXT_NODE
            ? range.endContainer.parentElement
            : range.endContainer;

        return (
            content.contains(startNode) &&
            content.contains(endNode)
        );
    }


    /**
     * 발췌 버튼 숨기기
     */
    function hideExcerptButton() {

        excerptBtn.classList.remove('is-visible');

        selectedText = '';
        selectedRange = null;
    }
    
    // 선택된 HTML에서 허용된 강조 서식만 안전하게 복사합니다.
    // font/span의 글꼴 정보, 링크, 이미지, 이벤트 속성은 복사하지 않습니다.
    function buildFormattedExcerpt(range) {
        const source = range.cloneContents();
        const output = document.createDocumentFragment();
        let currentParagraph = null;

        function paragraph() {
            if (!currentParagraph) {
                currentParagraph = document.createElement('p');
                output.appendChild(currentParagraph);
            }
            return currentParagraph;
        }

        function endParagraph() {
            currentParagraph = null;
        }

        function convert(node, destination) {
            if (node.nodeType === Node.TEXT_NODE) {
                // HTML 소스에 포함된 단순 줄바꿈은 공백으로 정규화합니다.
                const value = node.nodeValue.replace(/\s*\n\s*/g, ' ');
                if (value) destination.appendChild(document.createTextNode(value));
                return;
            }
            if (node.nodeType !== Node.ELEMENT_NODE) return;

            const tag = node.tagName.toLowerCase();
            if (['script', 'style', 'iframe', 'svg', 'img', 'video', 'audio'].includes(tag)) return;
            if (tag === 'br') {
                destination.appendChild(document.createElement('br'));
                return;
            }

            const styles = node.style;
            const wrappers = [];
            const weight = styles.fontWeight;
            if (tag === 'b' || tag === 'strong' || weight === 'bold' || Number(weight) >= 600) {
                wrappers.push('strong');
            }
            if (tag === 'i' || tag === 'em' || styles.fontStyle === 'italic' || styles.fontStyle === 'oblique') {
                wrappers.push('em');
            }
            if (tag === 'u' || styles.textDecorationLine.includes('underline')) {
                wrappers.push('u');
            }
            if (tag === 's' || tag === 'strike' || tag === 'del' || styles.textDecorationLine.includes('line-through')) {
                wrappers.push('s');
            }

            let target = destination;
            wrappers.forEach(function (wrapperTag) {
                const wrapper = document.createElement(wrapperTag);
                target.appendChild(wrapper);
                target = wrapper;
            });
            node.childNodes.forEach(child => convert(child, target));
        }

        function walk(node) {
            if (node.nodeType === Node.ELEMENT_NODE &&
                ['p', 'div', 'li', 'blockquote', 'h1', 'h2', 'h3', 'h4'].includes(node.tagName.toLowerCase())) {
                endParagraph();
                // 중첩 블록은 순서대로 각 문단으로 변환합니다.
                const hasBlockChild = Array.from(node.children).some(child =>
                    ['p', 'div', 'li', 'blockquote'].includes(child.tagName.toLowerCase()));
                if (hasBlockChild) {
                    node.childNodes.forEach(walk);
                } else {
                    const p = paragraph();
                    node.childNodes.forEach(child => convert(child, p));
                    if (!p.textContent.trim() && !p.querySelector('br')) p.appendChild(document.createElement('br'));
                }
                endParagraph();
            } else if (node.nodeType === Node.ELEMENT_NODE || node.nodeType === Node.TEXT_NODE) {
                convert(node, paragraph());
            }
        }

        source.childNodes.forEach(walk);
        // 선택 경계에서 발생한 불필요한 빈 문단만 제거합니다.
        while (output.firstChild && !output.firstChild.textContent.trim() &&
               !output.firstChild.querySelector('br')) output.firstChild.remove();
        while (output.lastChild && !output.lastChild.textContent.trim() &&
               !output.lastChild.querySelector('br')) output.lastChild.remove();
        return output;
    }

    function openExcerptModal(range) {
        if (!excerptModal || !excerptModalText || !range) return;
        excerptModalText.replaceChildren(buildFormattedExcerpt(range));
        updateExcerptPreview();
        excerptModal.classList.add('is-open');
        excerptModal.setAttribute('aria-hidden', 'false');
        document.body.style.overflow = 'hidden';
    }

    
function closeExcerptModal() {

    if (!excerptModal) {
        return;
    }

    excerptModal.classList.remove('is-open');
    excerptModal.setAttribute('aria-hidden', 'true');

    document.body.style.overflow = '';
}


    /**
     * 선택 영역 근처에 버튼 표시
     */
    function showExcerptButton(range) {

        const rect = range.getBoundingClientRect();

        if (
            rect.width === 0 &&
            rect.height === 0
        ) {
            hideExcerptButton();
            return;
        }

        excerptBtn.classList.add('is-visible');

        /*
         * 버튼이 실제로 렌더링된 다음 크기를 계산
         */
        requestAnimationFrame(function () {

            const btnRect = excerptBtn.getBoundingClientRect();

            /*
             * 기본 위치:
             * 선택 영역 아래쪽 중앙
             */
            let left =
                rect.left +
                (rect.width / 2) -
                (btnRect.width / 2);

            let top =
                rect.bottom + 8;


            /*
             * 화면 왼쪽/오른쪽 밖으로 나가지 않도록 제한
             */
            const margin = 8;

            left = Math.max(
                margin,
                Math.min(
                    left,
                    window.innerWidth - btnRect.width - margin
                )
            );


            /*
             * 아래 공간이 부족하면 선택 영역 위쪽에 표시
             */
            if (
                top + btnRect.height + margin >
                window.innerHeight
            ) {
                top =
                    rect.top -
                    btnRect.height -
                    8;
            }


            excerptBtn.style.left = `${left}px`;
            excerptBtn.style.top = `${top}px`;

        });
    }
    
    function updateExcerptPreview() {

    if (!excerptCard || !excerptModalText) {
        return;
    }

    excerptCard.style.backgroundColor = excerptStyleState.backgroundColor;
    excerptCard.style.color = excerptStyleState.textColor;
    excerptCard.style.padding = `${excerptStyleState.padding}px`;

    excerptModalText.style.fontWeight = excerptStyleState.fontWeight;
excerptModalText.style.fontSize = `${excerptStyleState.fontSize}px`;
excerptModalText.style.textAlign = excerptStyleState.textAlign;
excerptModalText.style.color = excerptStyleState.textColor;

    if (excerptModalSource) {
        excerptModalSource.style.color = excerptStyleState.textColor;
        excerptModalSource.style.textAlign = excerptStyleState.textAlign;

        if (excerptStyleState.showTitle) {

    const sourceParts = [];

    const customAuthor = excerptAuthorInput?.value.trim() || '';

    if (customAuthor) {
        sourceParts.push(customAuthor);
    }

    const customTitle = excerptTitleInput?.value.trim() || '';

    if (customTitle) {
        sourceParts.push(customTitle);
    }

    if (sourceParts.length > 0) {
        excerptModalSource.style.display = 'block';
        excerptModalSource.textContent = sourceParts.join(' | ');
    } else {
        excerptModalSource.style.display = 'none';
        excerptModalSource.textContent = '';
    }

} else {
    excerptModalSource.style.display = 'none';
    excerptModalSource.textContent = '';
}
    }

    if (excerptFontSizeValue) {
        excerptFontSizeValue.textContent = `${excerptStyleState.fontSize}px`;
    }

    if (excerptPaddingValue) {
        excerptPaddingValue.textContent = `${excerptStyleState.padding}px`;
    }

    excerptAlignButtons.forEach(function (button) {
        const isActive =
            button.dataset.align === excerptStyleState.textAlign;

        button.classList.toggle('is-active', isActive);
    });
    
    scheduleExcerptExport();
}


    /**
     * 텍스트 선택 처리
     */
    function handleSelection() {

        const selection = window.getSelection();

        if (
            !selection ||
            selection.rangeCount === 0 ||
            selection.isCollapsed
        ) {
            hideExcerptButton();
            return;
        }


        if (!isSelectionInsideContent(selection)) {
            hideExcerptButton();
            return;
        }


        const text = selection
    .toString()
    .trim();


        if (!text) {
            hideExcerptButton();
            return;
        }


        selectedText = text;
        selectedRange =
            selection
                .getRangeAt(0)
                .cloneRange();


        showExcerptButton(selectedRange);
    }


    /*
     * PC 마우스
     */
    content.addEventListener(
        'mouseup',
        function () {
            setTimeout(handleSelection, 0);
        }
    );


    /*
     * 모바일 터치 선택
     */
    content.addEventListener(
        'touchend',
        function () {
            setTimeout(handleSelection, 50);
        }
    );


    /*
     * 발췌 버튼 클릭
     */
    excerptBtn.addEventListener(
    'click',
    function (event) {

        event.preventDefault();
        event.stopPropagation();

        if (!selectedText) {
            return;
        }

        openExcerptModal(selectedRange);

        excerptBtn.classList.remove('is-visible');

        window.getSelection()?.removeAllRanges();
    }
);


    /*
     * 선택 영역 이외를 클릭하면 버튼 닫기
     */
    document.addEventListener(
        'mousedown',
        function (event) {

            if (
                event.target === excerptBtn ||
                excerptBtn.contains(event.target)
            ) {
                return;
            }

            /*
             * 본문 안에서 새롭게 드래그를 시작할 수도 있으므로
             * 여기서는 즉시 selection 자체를 제거하지 않습니다.
             */
            excerptBtn.classList.remove('is-visible');
        }
    );


    /*
     * 스크롤하면 기존 좌표가 무효가 되므로 숨김
     */
    window.addEventListener(
        'scroll',
        function () {
            excerptBtn.classList.remove('is-visible');
        },
        { passive: true }
    );


    /*
     * 화면 크기가 바뀌어도 위치가 무효가 됨
     */
    window.addEventListener(
        'resize',
        function () {
            excerptBtn.classList.remove('is-visible');
        }
    );
    
    if (excerptModalClose) {
        excerptModalClose.addEventListener(
            'click',
            closeExcerptModal
        );
    }


async function waitForMaruBuri() {
    if (!document.fonts) {
        return;
    }

    await Promise.all([
        document.fonts.load('200 18px MaruBuri'),
        document.fonts.load('300 18px MaruBuri'),
        document.fonts.load('400 18px MaruBuri'),
        document.fonts.load('600 18px MaruBuri'),
        document.fonts.load('700 18px MaruBuri')
    ]);

    await document.fonts.ready;
}


async function renderExcerptPngBlob() {

    if (!excerptCard) {
        throw new Error('발췌 카드가 없습니다.');
    }

    await waitForMaruBuri();

    const exportCard = excerptCard.cloneNode(true);

    /*
     * 같은 id가 두 개 생기는 걸 피하기 위해
     * export용 id로 변경
     */
    exportCard.id = 'excerpt-card-export';

    Object.assign(exportCard.style, {
    position: 'fixed',
    left: '-10000px',
    top: '0',

    width: `${EXPORT_BASE_WIDTH}px`,
    minWidth: `${EXPORT_BASE_WIDTH}px`,
    maxWidth: `${EXPORT_BASE_WIDTH}px`,
    minHeight: `${EXPORT_BASE_WIDTH}px`,

    boxSizing: 'border-box',

    fontFamily: "'MaruBuri', serif",

    /* 이 세 줄이 중요 */
    display: 'flex',
    flexDirection: 'column',
    justifyContent: 'center',

    transform: 'none',
    boxShadow: 'none'
});

    /*
     * cloneNode()는 inline style을 복사하므로
     * 현재 선택한 서식도 명시적으로 한번 더 적용
     */
    exportCard.style.backgroundColor =
        excerptStyleState.backgroundColor;

    exportCard.style.color =
        excerptStyleState.textColor;

    exportCard.style.padding =
        `${excerptStyleState.padding}px`;

    const exportText =
        exportCard.querySelector('#excerpt-modal-text');

    const exportSource =
        exportCard.querySelector('#excerpt-modal-source');

    if (exportText) {
        exportText.style.fontFamily = "'MaruBuri', serif";
        exportText.style.fontWeight = excerptStyleState.fontWeight;
        exportText.style.fontSize =
            `${excerptStyleState.fontSize}px`;
        exportText.style.textAlign =
            excerptStyleState.textAlign;
        exportText.style.color =
            excerptStyleState.textColor;
    }

    if (exportSource) {
        exportSource.style.fontFamily = "'MaruBuri', serif";
        exportSource.style.textAlign =
            excerptStyleState.textAlign;
        exportSource.style.color =
            excerptStyleState.textColor;
    }

    document.body.appendChild(exportCard);

    await new Promise(resolve => {
        requestAnimationFrame(() => {
            requestAnimationFrame(resolve);
        });
    });

    try {

        const canvas = await html2canvas(exportCard, {
            backgroundColor: null,
            scale: EXPORT_SCALE,
            useCORS: true,
            logging: false,
            width: EXPORT_BASE_WIDTH,
            windowWidth: 1200
        });

        return await new Promise((resolve, reject) => {

            canvas.toBlob(
                function (blob) {

                    if (!blob) {
                        reject(
                            new Error('PNG Blob 생성 실패')
                        );
                        return;
                    }

                    resolve(blob);
                },
                'image/png'
            );

        });

    } finally {
        exportCard.remove();
    }
}


function scheduleExcerptExport() {

    preparedPngBlob = null;

    clearTimeout(exportRenderTimer);

    const currentVersion = ++exportRenderVersion;

    if (excerptSaveBtn) {
        excerptSaveBtn.disabled = true;
        excerptSaveBtn.textContent = 'PNG 준비 중...';
    }

    exportRenderTimer = setTimeout(async function () {

        try {

            const blob =
                await renderExcerptPngBlob();

            /*
             * 이미지 만드는 사이 옵션이 바뀌었다면
             * 이전 결과를 사용하지 않음
             */
            if (
                currentVersion !==
                exportRenderVersion
            ) {
                return;
            }

            preparedPngBlob = blob;

            if (excerptSaveBtn) {
                excerptSaveBtn.disabled = false;
                excerptSaveBtn.textContent = 'PNG로 저장';
            }

        } catch (error) {

            console.error(
                '발췌 PNG 생성 실패:',
                error
            );

            if (excerptSaveBtn) {
                excerptSaveBtn.disabled = true;
                excerptSaveBtn.textContent = 'PNG 생성 실패';
            }
        }

    }, 200);
}

async function saveExcerptAsPng() {

    if (!preparedPngBlob) {
        return;
    }

    const currentTitle = excerptTitleInput?.value.trim() || '';

    const safeTitle = currentTitle
        ? currentTitle.replace(/[\\/:*?"<>|]/g, '_')
        : 'excerpt';

    const fileName = `${safeTitle}_발췌.png`;

    const file = new File(
        [preparedPngBlob],
        fileName,
        {
            type: 'image/png'
        }
    );

    const isIOS =
        /iPad|iPhone|iPod/.test(navigator.userAgent) ||
        (
            navigator.platform === 'MacIntel' &&
            navigator.maxTouchPoints > 1
        );


    /*
     * iOS만 공유 시트
     */
    if (
        isIOS &&
        navigator.share &&
        navigator.canShare &&
        navigator.canShare({
            files: [file]
        })
    ) {

        try {

            await navigator.share({
                files: [file]
            });

            return;

        } catch (error) {

            if (error.name === 'AbortError') {
                return;
            }

            console.warn(
                'iOS 공유 실패, 다운로드로 전환:',
                error
            );
        }
    }


    /*
     * PC 및 share 실패 시 일반 PNG 다운로드
     */
    const objectUrl =
        URL.createObjectURL(preparedPngBlob);

    const link =
        document.createElement('a');

    link.href = objectUrl;
    link.download = fileName;

    document.body.appendChild(link);
    link.click();
    link.remove();

    setTimeout(function () {
        URL.revokeObjectURL(objectUrl);
    }, 10000);
}

if (excerptAuthorInput) {
        excerptAuthorInput.addEventListener('input', function () {
            updateExcerptPreview();
        });
    }


if (excerptTitleInput) {
    excerptTitleInput.addEventListener('input', function () {
        updateExcerptPreview();
    });
}

if (excerptSaveBtn) {

    excerptSaveBtn.addEventListener(
        'click',
        saveExcerptAsPng
    );

}


if (excerptModalBackdrop) {
    excerptModalBackdrop.addEventListener(
        'click',
        closeExcerptModal
    );
}

if (excerptFontWeight) {
    excerptFontWeight.addEventListener('change', function () {
        excerptStyleState.fontWeight = Number(this.value);
        updateExcerptPreview();
    });
}

if (excerptFontSize) {
    excerptFontSize.addEventListener('input', function () {
        excerptStyleState.fontSize = Number(this.value);
        updateExcerptPreview();
    });
}

if (excerptBgColor) {
    excerptBgColor.addEventListener('input', function () {
        excerptStyleState.backgroundColor = this.value;
        updateExcerptPreview();
    });
}

if (excerptTextColor) {
    excerptTextColor.addEventListener('input', function () {
        excerptStyleState.textColor = this.value;
        updateExcerptPreview();
    });
}

if (excerptPadding) {
    excerptPadding.addEventListener('input', function () {
        excerptStyleState.padding = Number(this.value);
        updateExcerptPreview();
    });
}

if (excerptShowTitle) {
    excerptShowTitle.addEventListener('change', function () {
        excerptStyleState.showTitle = this.checked;
        updateExcerptPreview();
    });
}

excerptAlignButtons.forEach(function (button) {
    button.addEventListener('click', function () {
        excerptStyleState.textAlign = this.dataset.align;
        updateExcerptPreview();
    });
});

updateExcerptPreview();


document.addEventListener(
    'keydown',
    function (event) {

        if (
            event.key === 'Escape' &&
            excerptModal?.classList.contains('is-open')
        ) {
            closeExcerptModal();
        }
    }
);

});
</script>


읽어주셔서 감사합니다.


첨부파일

댓글목록

등록된 댓글이 없습니다.