- 미리보기 영역에 contenteditable 속성 추가 (편집 가능) - 번호 목록(1. 2. 3.)이 한 줄에 이어진 경우 자동 줄바꿈 분리 - 편집 시 포커스 시각 표시(보라색 아웃라인) 추가
400 lines
15 KiB
PHP
400 lines
15 KiB
PHP
@extends('layouts.app')
|
|
|
|
@section('title', '클코 → 슬랙 변환기')
|
|
|
|
@section('content')
|
|
<style>
|
|
:root {
|
|
--cs-bg: #0f172a;
|
|
--cs-card: #1e293b;
|
|
--cs-card2: #334155;
|
|
--cs-border: #475569;
|
|
--cs-text: #f1f5f9;
|
|
--cs-text2: #94a3b8;
|
|
--cs-purple: #8b5cf6;
|
|
--cs-green: #10b981;
|
|
--cs-blue: #3b82f6;
|
|
--cs-amber: #f59e0b;
|
|
--cs-red: #ef4444;
|
|
}
|
|
.cs-wrap {
|
|
display: flex; flex-direction: column; height: calc(100vh - 64px);
|
|
background: var(--cs-bg); overflow: hidden;
|
|
font-family: 'Pretendard', -apple-system, sans-serif; color: var(--cs-text);
|
|
margin: -24px;
|
|
}
|
|
.cs-toolbar {
|
|
display: flex; align-items: center; height: 48px; padding: 0 16px;
|
|
background: var(--cs-card); border-bottom: 1px solid var(--cs-border);
|
|
gap: 12px; flex-shrink: 0;
|
|
}
|
|
.cs-toolbar h1 { font-size: 14px; font-weight: 700; margin: 0; display: flex; align-items: center; gap: 6px; }
|
|
.cs-toolbar h1 i { color: var(--cs-purple); }
|
|
.cs-btn {
|
|
display: inline-flex; align-items: center; gap: 5px; padding: 6px 12px;
|
|
border-radius: 6px; font-size: 12px; font-weight: 500; cursor: pointer;
|
|
border: 1px solid var(--cs-border); background: var(--cs-card2); color: var(--cs-text);
|
|
transition: all .15s;
|
|
}
|
|
.cs-btn:hover { background: #475569; }
|
|
.cs-btn.success { background: var(--cs-green); border-color: var(--cs-green); color: #fff; }
|
|
.cs-btn.success:hover { background: #059669; }
|
|
.cs-btn.danger { background: var(--cs-red); border-color: var(--cs-red); color: #fff; }
|
|
|
|
.cs-body { display: flex; flex: 1; overflow: hidden; }
|
|
.cs-panel {
|
|
flex: 1; display: flex; flex-direction: column; overflow: hidden;
|
|
border-right: 1px solid var(--cs-border);
|
|
}
|
|
.cs-panel:last-child { border-right: none; }
|
|
.cs-panel-header {
|
|
display: flex; align-items: center; justify-content: space-between;
|
|
padding: 8px 12px; background: var(--cs-card);
|
|
border-bottom: 1px solid var(--cs-border); flex-shrink: 0;
|
|
}
|
|
.cs-panel-header .title {
|
|
font-size: 12px; font-weight: 600; color: var(--cs-text2);
|
|
display: flex; align-items: center; gap: 6px;
|
|
}
|
|
.cs-panel-header .actions { display: flex; gap: 6px; }
|
|
|
|
.cs-editor {
|
|
flex: 1; width: 100%; background: transparent; color: var(--cs-text);
|
|
border: none; outline: none; resize: none; padding: 12px;
|
|
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
|
|
font-size: 13px; line-height: 1.6;
|
|
}
|
|
.cs-editor::placeholder { color: var(--cs-text2); }
|
|
|
|
.cs-preview {
|
|
flex: 1; overflow-y: auto; padding: 12px;
|
|
font-family: 'Pretendard', -apple-system, sans-serif;
|
|
font-size: 15px; line-height: 1.7; color: #d1d2d3;
|
|
background: #1a1d21;
|
|
}
|
|
.cs-preview .slack-bold { font-weight: 700; color: #f1f1f1; }
|
|
.cs-preview .slack-italic { font-style: italic; }
|
|
.cs-preview .slack-strike { text-decoration: line-through; }
|
|
.cs-preview .slack-code {
|
|
background: #2d2f33; color: #e01e5a; padding: 2px 5px;
|
|
border-radius: 3px; font-family: 'JetBrains Mono', monospace; font-size: 13px;
|
|
border: 1px solid #3c3f44;
|
|
}
|
|
.cs-preview .slack-codeblock {
|
|
background: #2d2f33; border: 1px solid #3c3f44; border-radius: 4px;
|
|
padding: 8px 12px; margin: 4px 0; font-family: 'JetBrains Mono', monospace;
|
|
font-size: 13px; line-height: 1.5; white-space: pre-wrap; overflow-x: auto;
|
|
}
|
|
.cs-preview .slack-blockquote {
|
|
border-left: 4px solid #5c5e63; padding-left: 12px; margin: 4px 0;
|
|
color: var(--cs-text2);
|
|
}
|
|
.cs-preview .slack-link { color: #1d9bd1; text-decoration: none; }
|
|
.cs-preview .slack-hr { border: none; border-top: 1px solid #3c3f44; margin: 8px 0; }
|
|
.cs-preview:focus { outline: 1px solid var(--cs-purple); outline-offset: -1px; }
|
|
|
|
.cs-status {
|
|
display: flex; align-items: center; padding: 6px 16px;
|
|
background: var(--cs-card); border-top: 1px solid var(--cs-border);
|
|
font-size: 11px; color: var(--cs-text2); gap: 16px; flex-shrink: 0;
|
|
}
|
|
.cs-toast {
|
|
position: fixed; top: 16px; right: 16px; padding: 10px 16px;
|
|
background: var(--cs-green); color: #fff; border-radius: 8px;
|
|
font-size: 13px; font-weight: 500; z-index: 9999;
|
|
transform: translateY(-20px); opacity: 0; transition: all .3s;
|
|
pointer-events: none;
|
|
}
|
|
.cs-toast.show { transform: translateY(0); opacity: 1; }
|
|
</style>
|
|
|
|
<div class="cs-wrap">
|
|
<div class="cs-toolbar">
|
|
<h1><i class="fas fa-exchange-alt"></i> 클코 → 슬랙 변환기</h1>
|
|
<div style="flex:1"></div>
|
|
<button class="cs-btn danger" onclick="clearAll()"><i class="fas fa-trash-alt"></i> 초기화</button>
|
|
</div>
|
|
|
|
<div class="cs-body">
|
|
<!-- 입력 -->
|
|
<div class="cs-panel">
|
|
<div class="cs-panel-header">
|
|
<span class="title"><i class="fas fa-terminal"></i> Claude Code 출력 (붙여넣기)</span>
|
|
<div class="actions">
|
|
<button class="cs-btn" onclick="pasteFromClipboard()"><i class="fas fa-paste"></i> 붙여넣기</button>
|
|
</div>
|
|
</div>
|
|
<textarea id="inputArea" class="cs-editor"
|
|
placeholder="Claude Code CLI 출력을 여기에 붙여넣으세요..."
|
|
oninput="convert()" spellcheck="false"></textarea>
|
|
</div>
|
|
|
|
<!-- 슬랙 미리보기 -->
|
|
<div class="cs-panel">
|
|
<div class="cs-panel-header">
|
|
<span class="title"><i class="fab fa-slack"></i> 슬랙 미리보기</span>
|
|
<div class="actions">
|
|
<button class="cs-btn success" onclick="copyPreview()"><i class="fas fa-copy"></i> 복사</button>
|
|
</div>
|
|
</div>
|
|
<div id="previewArea" class="cs-preview" contenteditable="true">
|
|
<div style="color:var(--cs-text2); padding:40px 20px; text-align:center;">
|
|
<i class="fas fa-arrow-left" style="font-size:24px; margin-bottom:12px; display:block;"></i>
|
|
왼쪽에 Claude Code 출력을 붙여넣으면<br>슬랙에 바로 붙여넣을 수 있는 형태로 변환됩니다
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="cs-status">
|
|
<span>Ctrl+V → 자동 변환 → 복사 → 슬랙에 붙여넣기</span>
|
|
<div style="flex:1"></div>
|
|
<span><kbd style="background:var(--cs-card2); border:1px solid var(--cs-border); padding:1px 5px; border-radius:3px; font-size:10px;">Ctrl+Enter</kbd> 빠른 복사</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div id="toast" class="cs-toast"></div>
|
|
|
|
@endsection
|
|
|
|
@push('scripts')
|
|
<script>
|
|
(function() {
|
|
const inputArea = document.getElementById('inputArea');
|
|
const previewArea = document.getElementById('previewArea');
|
|
|
|
function convertToSlack(text) {
|
|
if (!text.trim()) return '';
|
|
|
|
// 번호 목록이 한 줄에 이어진 경우 줄바꿈으로 분리
|
|
// e.g., "내용 1. 항목 2. 항목" → "내용\n1. 항목\n2. 항목"
|
|
text = text.replace(/([^\d\n])\s+(\d{1,2})\.\s/g, '$1\n$2. ');
|
|
|
|
let lines = text.split('\n');
|
|
let result = [];
|
|
let inCodeBlock = false;
|
|
let codeLines = [];
|
|
let inTable = false;
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
let line = lines[i];
|
|
|
|
if (line.trimStart().match(/^```/)) {
|
|
if (!inCodeBlock) {
|
|
inCodeBlock = true;
|
|
inTable = false;
|
|
codeLines = [];
|
|
continue;
|
|
} else {
|
|
inCodeBlock = false;
|
|
result.push('```');
|
|
result.push(...codeLines);
|
|
result.push('```');
|
|
codeLines = [];
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (inCodeBlock) {
|
|
codeLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
// 표 구분선(|---|---|) 스킵
|
|
if (line.trim().match(/^\|[\s\-:|]+\|$/)) {
|
|
inTable = true;
|
|
continue;
|
|
}
|
|
|
|
// 표 행
|
|
if (line.trim().match(/^\|.*\|$/)) {
|
|
inTable = true;
|
|
let cells = line.split('|').filter(c => c.trim() !== '');
|
|
result.push(cells.map(c => c.trim()).join(' | '));
|
|
continue;
|
|
}
|
|
|
|
// 표 영역 종료 후 빈 줄 삽입
|
|
if (inTable) {
|
|
inTable = false;
|
|
if (result.length > 0 && result[result.length - 1] !== '') {
|
|
result.push('');
|
|
}
|
|
}
|
|
|
|
if (line.trim() === '') {
|
|
if (result.length > 0 && result[result.length - 1] !== '') {
|
|
result.push('');
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (line.trim().match(/^[-]{3,}$/)) {
|
|
result.push('———');
|
|
result.push('');
|
|
continue;
|
|
}
|
|
|
|
let headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
if (headingMatch) {
|
|
result.push('*' + headingMatch[2].trim() + '*');
|
|
result.push('');
|
|
continue;
|
|
}
|
|
|
|
line = convertInlineMarkdown(line);
|
|
result.push(line);
|
|
result.push('');
|
|
}
|
|
|
|
if (inCodeBlock && codeLines.length > 0) {
|
|
result.push('```');
|
|
result.push(...codeLines);
|
|
result.push('```');
|
|
}
|
|
|
|
while (result.length > 0 && result[result.length - 1] === '') result.pop();
|
|
return result.join('\n');
|
|
}
|
|
|
|
function convertInlineMarkdown(line) {
|
|
let codeSegments = [];
|
|
line = line.replace(/`([^`]+)`/g, function(m, code) {
|
|
codeSegments.push(code);
|
|
return '\x00CODE' + (codeSegments.length - 1) + '\x00';
|
|
});
|
|
|
|
line = line.replace(/\*\*(.+?)\*\*/g, '*$1*');
|
|
line = line.replace(/__(.+?)__/g, '*$1*');
|
|
line = line.replace(/(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)/g, '_$1_');
|
|
line = line.replace(/~~(.+?)~~/g, '~$1~');
|
|
line = line.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<$2|$1>');
|
|
|
|
line = line.replace(/\x00CODE(\d+)\x00/g, function(m, idx) {
|
|
return '`' + codeSegments[parseInt(idx)] + '`';
|
|
});
|
|
return line;
|
|
}
|
|
|
|
function renderSlackPreview(text) {
|
|
if (!text.trim()) return '';
|
|
|
|
let lines = text.split('\n');
|
|
let html = [];
|
|
let inCodeBlock = false;
|
|
let codeLines = [];
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
let line = lines[i];
|
|
|
|
if (line.trim() === '```') {
|
|
if (!inCodeBlock) {
|
|
inCodeBlock = true;
|
|
codeLines = [];
|
|
continue;
|
|
} else {
|
|
inCodeBlock = false;
|
|
html.push('<div class="slack-codeblock">' + escapeHtml(codeLines.join('\n')) + '</div>');
|
|
codeLines = [];
|
|
continue;
|
|
}
|
|
}
|
|
|
|
if (inCodeBlock) { codeLines.push(line); continue; }
|
|
if (line.trim() === '') { html.push('<div style="height:8px"></div>'); continue; }
|
|
if (line.trim() === '———') { html.push('<hr class="slack-hr">'); continue; }
|
|
|
|
let rendered = renderInlineSlack(escapeHtml(line));
|
|
html.push('<div>' + rendered + '</div>');
|
|
}
|
|
|
|
if (inCodeBlock && codeLines.length > 0) {
|
|
html.push('<div class="slack-codeblock">' + escapeHtml(codeLines.join('\n')) + '</div>');
|
|
}
|
|
return html.join('\n');
|
|
}
|
|
|
|
function renderInlineSlack(line) {
|
|
line = line.replace(/`([^`]+)`/g, '<span class="slack-code">$1</span>');
|
|
line = line.replace(/(?<!\w)\*([^*]+)\*(?!\w)/g, '<span class="slack-bold">$1</span>');
|
|
line = line.replace(/(?<!\w)_([^_]+)_(?!\w)/g, '<span class="slack-italic">$1</span>');
|
|
line = line.replace(/(?<!\w)~([^~]+)~(?!\w)/g, '<span class="slack-strike">$1</span>');
|
|
line = line.replace(/<(https?:\/\/[^|]+)\|([^&]+)>/g, '<a class="slack-link" href="#">$2</a>');
|
|
return line;
|
|
}
|
|
|
|
function escapeHtml(t) {
|
|
return t.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
}
|
|
|
|
/**
|
|
* 미리보기 영역의 리치 텍스트를 클립보드에 복사
|
|
* → 슬랙에 붙여넣으면 서식이 보존됨
|
|
*/
|
|
window.copyPreview = function() {
|
|
const content = previewArea.innerHTML;
|
|
if (!content || previewArea.querySelectorAll('div, span, hr').length === 0) {
|
|
showToast('복사할 내용이 없습니다', 'warning');
|
|
return;
|
|
}
|
|
|
|
// 방법 1: Selection API로 리치 텍스트 복사
|
|
const range = document.createRange();
|
|
range.selectNodeContents(previewArea);
|
|
const selection = window.getSelection();
|
|
selection.removeAllRanges();
|
|
selection.addRange(range);
|
|
|
|
try {
|
|
document.execCommand('copy');
|
|
showToast('복사 완료! 슬랙에 Ctrl+V로 붙여넣으세요');
|
|
} catch(e) {
|
|
showToast('복사 실패 — 미리보기를 드래그해서 직접 복사해주세요', 'warning');
|
|
}
|
|
|
|
selection.removeAllRanges();
|
|
};
|
|
|
|
window.convert = function() {
|
|
const input = inputArea.value;
|
|
const output = convertToSlack(input);
|
|
previewArea.innerHTML = renderSlackPreview(output) ||
|
|
'<div style="color:var(--cs-text2); padding:40px 20px; text-align:center;"><i class="fas fa-arrow-left" style="font-size:24px; margin-bottom:12px; display:block;"></i>왼쪽에 Claude Code 출력을 붙여넣으면<br>슬랙에 바로 붙여넣을 수 있는 형태로 변환됩니다</div>';
|
|
};
|
|
|
|
window.pasteFromClipboard = function() {
|
|
navigator.clipboard.readText().then(text => {
|
|
inputArea.value = text;
|
|
convert();
|
|
showToast('붙여넣기 완료!');
|
|
}).catch(() => {
|
|
inputArea.focus();
|
|
showToast('Ctrl+V로 직접 붙여넣어주세요', 'warning');
|
|
});
|
|
};
|
|
|
|
window.clearAll = function() {
|
|
inputArea.value = '';
|
|
previewArea.innerHTML = '<div style="color:var(--cs-text2); padding:40px 20px; text-align:center;"><i class="fas fa-arrow-left" style="font-size:24px; margin-bottom:12px; display:block;"></i>왼쪽에 Claude Code 출력을 붙여넣으면<br>슬랙에 바로 붙여넣을 수 있는 형태로 변환됩니다</div>';
|
|
inputArea.focus();
|
|
};
|
|
|
|
function showToast(msg, type) {
|
|
const toast = document.getElementById('toast');
|
|
toast.textContent = msg;
|
|
toast.style.background = type === 'warning' ? 'var(--cs-amber)' : 'var(--cs-green)';
|
|
toast.classList.add('show');
|
|
setTimeout(() => toast.classList.remove('show'), 2000);
|
|
}
|
|
|
|
inputArea.addEventListener('paste', function() {
|
|
setTimeout(() => convert(), 50);
|
|
});
|
|
|
|
document.addEventListener('keydown', function(e) {
|
|
if (e.ctrlKey && e.key === 'Enter') copyPreview();
|
|
});
|
|
})();
|
|
</script>
|
|
@endpush
|