범용 발췌 모달
라공에디션/아보카도 에디션/티스토리 등에서 사용하실 수 있는 발췌 모달을 배포합니다.
원하시는 곳에 편하게 넣어주시면 됩니다.
언제나 그러하듯 AI의 도움을 받았습니다.
도움이 되었다면 배포 트윗 인용으로 유키 화이트 화이팅!! 한번만 외쳐주세요.
코드 수정 및 재배포는 언제나 자유롭게. 돈만 거시지 마세요.
질문은 언제나 이쪽 URL입니다.
이 모달을 사용하시면 이러한 것을 만드실 수 있습니다.

필요 라이브러리
> 위치 : 들어가는 아무데나 괜찮습니다.
<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>읽어주셔서 감사합니다.
첨부파일
- 다음글[라공 에디션] 라공 에디터 스마트 구두점/스마트 따옴표 플러그인 26.07.21
댓글목록
등록된 댓글이 없습니다.