Add TradingView chart widget + MIDI/tab close fixes

- investor skill: TradingView 실시간 주가 차트 렌더링 지원
  - ```tradingview SYMBOL|INTERVAL``` 블록 → 차트 위젯 자동 생성
  - JSON 형식 출력 시 HTML 언이스케이프 후 파싱 (symbol/interval 추출)
  - renderTVChart(): tv.js 동적 로드, 다크/라이트 테마 연동, RSI·MACD 기본 포함
- musician skill: MIDI 다운로드 사이트 4개 추가 (freemidi, bitmidi, piano-midi, midiworld)
- MIDI 탭 닫기 버그: window.open에 noopener,noreferrer 추가
- beforeunload/pagehide 시 midiRouter.stop() 호출

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
kim
2026-05-21 23:26:28 +09:00
co-authored by Claude Sonnet 4.6
parent 00d508cc77
commit 6df1bcf5b3
3 changed files with 163 additions and 3 deletions
+76 -2
View File
@@ -9253,6 +9253,37 @@ function renderMarkdown(text, opts) {
'</div>' +
'</div>';
}
if (lang === 'tradingview') {
var tvSymbol = '', tvInterval = 'D';
// code content is already HTML-escaped at this point — unescape before parsing
var tvRaw = trimmed.replace(/&amp;/g,'&').replace(/&lt;/g,'<').replace(/&gt;/g,'>').replace(/&quot;/gi,'"').replace(/&#39;/g,"'").trim();
if (tvRaw.startsWith('{')) {
try {
var tvJson = JSON.parse(tvRaw);
tvSymbol = (tvJson.symbol || tvJson.SYMBOL || '').toString().toUpperCase();
tvInterval = (tvJson.interval || tvJson.INTERVAL || 'D').toString().toUpperCase();
} catch(e) { tvSymbol = tvRaw.split('|')[0].trim().toUpperCase(); }
} else {
var parts = tvRaw.split('|');
tvSymbol = (parts[0] || '').trim().toUpperCase();
tvInterval = (parts[1] || 'D').trim().toUpperCase();
}
if (!tvSymbol) return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
var tvId = 'tv-' + Math.random().toString(36).slice(2, 9);
return '<div class="music-widget" id="' + tvId + '">' +
'<div class="music-widget-header">' +
'<span class="music-widget-label">📈 ' + escHtml(tvSymbol) + ' · ' + escHtml(tvInterval) + '</span>' +
'<div style="display:flex;gap:6px;align-items:center">' +
'<button class="music-render-btn" onclick="renderTVChart(this,\'' + tvId + '-c\',\'' + escHtml(tvSymbol) + '\',\'' + escHtml(tvInterval) + '\')">📈 차트 불러오기</button>' +
'<button onclick="toggleMusicWidget(\'' + tvId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="접기/펼치기">▾</button>' +
'<button onclick="closeMusicWidget(\'' + tvId + '\')" style="background:none;border:none;cursor:pointer;font-size:14px;color:var(--muted);padding:2px 4px" title="닫기">✕</button>' +
'</div>' +
'</div>' +
'<div class="music-widget-body">' +
'<div id="' + tvId + '-c" style="height:440px;width:100%"></div>' +
'</div>' +
'</div>';
}
return '<pre class="md-code-block"><code>' + trimmed + '</code></pre>';
});
@@ -9286,6 +9317,45 @@ function renderMarkdown(text, opts) {
return html;
}
function renderTVChart(btn, containerId, symbol, interval) {
btn.disabled = true;
btn.textContent = '로딩 중...';
var isDark = document.documentElement.getAttribute('data-theme') !== 'light';
function doInit() {
try {
new TradingView.widget({
container_id: containerId,
autosize: true,
symbol: symbol,
interval: interval || 'D',
timezone: 'Asia/Seoul',
theme: isDark ? 'dark' : 'light',
style: '1',
locale: 'kr',
enable_publishing: false,
allow_symbol_change: true,
save_image: false,
withdateranges: true,
hide_side_toolbar: false,
studies: ['RSI@tv-basicstudies', 'MACD@tv-basicstudies'],
});
btn.style.display = 'none';
} catch(e) {
btn.disabled = false;
btn.textContent = '📈 차트 불러오기';
}
}
if (window.TradingView && window.TradingView.widget) {
doInit();
} else {
var s = document.createElement('script');
s.src = 'https://s3.tradingview.com/tv.js';
s.onload = doInit;
s.onerror = function() { btn.disabled = false; btn.textContent = '📈 차트 불러오기'; };
document.head.appendChild(s);
}
}
function toggleMusicWidget(widgetId) {
var widget = document.getElementById(widgetId);
if (!widget) return;
@@ -9495,7 +9565,7 @@ function buildMusicActions(data, downloadName) {
+ '<span style="font-size:11px;color:var(--muted)" id="' + rid + '-st"></span>'
+ '</div>';
html += '<button class="music-dl-btn btn-green" id="' + rid + '-btn" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '-btn\')">▶ 외부 신디</button>';
html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">🎹 편집</button>';
html += '<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>';
html += '<a class="music-dl-btn" href="' + midiUrl + '" download="' + escHtml(downloadName || 'score.midi') + '">⬇ MIDI</a>';
}
if (pdfUrl) html += '<a class="music-dl-btn" href="' + pdfUrl + '" download="score.pdf">⬇ PDF</a>';
@@ -9616,7 +9686,7 @@ function buildMidiPickerWidget(data, filename) {
savedLine +
'<div style="display:flex;gap:8px;flex-wrap:wrap;align-items:center">' +
'<button class="music-dl-btn btn-green" onclick="midiRouterToggle(\'' + rid + '\',\'' + rid + '\')">▶ 재생</button>' +
'<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\')">🎹 편집</button>' +
'<button class="music-dl-btn btn-purple" onclick="window.open(\'/midi-editor.html?url=\'+encodeURIComponent(\'' + midiUrl + '\'),\'_blank\',\'noopener,noreferrer\')">🎹 편집</button>' +
'<button class="music-dl-btn btn-amber" id="' + wid + '-score-btn" onclick="midiPickerScore(\'' + wid + '\',\'' + data.id + '\',\'' + escHtml(filename) + '\')">' + scoreBtnLabel + '</button>' +
'</div>' +
playerPanel +
@@ -11061,6 +11131,10 @@ function canvasRun() {
});
})();
// Stop MIDI playback when tab is closed or navigated away
window.addEventListener('beforeunload', () => { try { midiRouter.stop(); } catch {} });
window.addEventListener('pagehide', () => { try { midiRouter.stop(); } catch {} });
// Resize xterm when window resizes
window.addEventListener('resize', () => {
const shell = getActiveShell();