feat: initial public release (MAESTRO)
This commit is contained in:
@@ -0,0 +1,791 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Marked, Renderer } from 'marked';
|
||||
import DOMPurify from 'dompurify';
|
||||
import mermaid from 'mermaid';
|
||||
import hljs from 'highlight.js';
|
||||
import { updateLocalFileContent } from '../../api';
|
||||
import { EmbedBlock } from '../embed/EmbedBlock';
|
||||
import { OUTPUT_PATH_REGEX, linkifyOutputPathsInEscapedHtml } from '../../lib/output-path-detect';
|
||||
|
||||
mermaid.initialize({ startOnLoad: false, theme: 'default' });
|
||||
|
||||
// --- MDXG outline helpers ---
|
||||
interface OutlineEntry { depth: 1 | 2 | 3; text: string; slug: string; }
|
||||
|
||||
function slugify(text: string): string {
|
||||
const cleaned = text
|
||||
.replace(/<[^>]+>/g, '')
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[\s_]+/g, '-')
|
||||
.replace(/[^\w\--ヿ一-鿿]/g, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
return cleaned || 'section';
|
||||
}
|
||||
|
||||
function buildSlugger() {
|
||||
const counts = new Map<string, number>();
|
||||
return (text: string): string => {
|
||||
const base = slugify(text);
|
||||
const n = counts.get(base) ?? 0;
|
||||
counts.set(base, n + 1);
|
||||
return n === 0 ? base : `${base}-${n}`;
|
||||
};
|
||||
}
|
||||
|
||||
function extractOutline(content: string, parser: Marked): OutlineEntry[] {
|
||||
const tokens = parser.lexer(content);
|
||||
const headings: OutlineEntry[] = [];
|
||||
const slugger = buildSlugger();
|
||||
for (const t of tokens) {
|
||||
if (t.type === 'heading') {
|
||||
const depth = (t as { depth?: number }).depth;
|
||||
const text = (t as { text?: string }).text ?? '';
|
||||
if (depth === 1 || depth === 2 || depth === 3) {
|
||||
headings.push({ depth, text, slug: slugger(text) });
|
||||
}
|
||||
}
|
||||
}
|
||||
return headings;
|
||||
}
|
||||
|
||||
interface FilePreviewProps {
|
||||
name: string;
|
||||
content: string;
|
||||
imageSrc: string;
|
||||
/** Markdown 内の相対パス画像を解決するためのベース URL (省略可) */
|
||||
markdownImageBaseUrl?: string;
|
||||
onClose: () => void;
|
||||
taskId?: number;
|
||||
section?: string;
|
||||
filePath?: string;
|
||||
editable?: boolean;
|
||||
}
|
||||
|
||||
// --- CSV ---
|
||||
function renderCsv(csv: string) {
|
||||
const rows = csv.trim().split(/\r?\n/).map(r => r.split(','));
|
||||
if (rows.length === 0) return null;
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-xs border-collapse">
|
||||
<tbody>
|
||||
{rows.slice(0, 120).map((r, i) => (
|
||||
<tr key={i}>
|
||||
{r.slice(0, 20).map((c, j) => (
|
||||
<td key={j} className={`border border-slate-200 px-2 py-1 ${i === 0 ? 'bg-slate-100 font-bold' : 'bg-white'}`}>
|
||||
{c}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Markdown (marked) ---
|
||||
function escapeHtml(s: string): string {
|
||||
return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
||||
}
|
||||
|
||||
function buildMdRenderer(opts: { imageBaseUrl?: string; slugger?: (text: string) => string }): Renderer {
|
||||
const { imageBaseUrl, slugger } = opts;
|
||||
const renderer = new Renderer();
|
||||
renderer.link = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
// Markdown link whose destination is an `output/...` workspace
|
||||
// path: route the click through OutputPreviewProvider's
|
||||
// delegation instead of opening a new tab. Visible text is the
|
||||
// markdown label.
|
||||
const isOutputHref = OUTPUT_PATH_REGEX.test(href);
|
||||
OUTPUT_PATH_REGEX.lastIndex = 0;
|
||||
if (isOutputHref) {
|
||||
return `<a class="output-path-link" data-output-path="${href.replace(/"/g, '"')}"${titleAttr} role="button" tabindex="0">${text}</a>`;
|
||||
}
|
||||
return `<a href="${href}"${titleAttr} target="_blank" rel="noopener noreferrer">${text}</a>`;
|
||||
};
|
||||
// Bare `output/...` paths in paragraph / list / blockquote text.
|
||||
// Marked passes pre-escaped HTML strings here, so the linkifier
|
||||
// runs on safe content.
|
||||
//
|
||||
// CRITICAL: when the text token has nested inline children (strong,
|
||||
// em, codespan, link), `tokens` is set and we MUST defer to
|
||||
// parser.parseInline. Otherwise the inline formatting is silently
|
||||
// dropped — `- **bold** \`code\`` would render as literal `**bold**
|
||||
// \`code\`` instead of formatted.
|
||||
renderer.text = function ({ tokens, text }: { tokens?: unknown[]; text: string }) {
|
||||
if (tokens && tokens.length > 0) {
|
||||
const self = this as unknown as { parser: { parseInline(tokens: unknown[]): string } };
|
||||
return self.parser.parseInline(tokens);
|
||||
}
|
||||
return linkifyOutputPathsInEscapedHtml(text);
|
||||
};
|
||||
// Inline `output/foo.md` in single-backtick spans. Fenced code
|
||||
// blocks go through `renderer.code` (below) which deliberately
|
||||
// doesn't linkify — fenced code is usually a copy-paste sample,
|
||||
// not a real reference.
|
||||
renderer.codespan = function ({ text }: { text: string }) {
|
||||
return `<code>${linkifyOutputPathsInEscapedHtml(text)}</code>`;
|
||||
};
|
||||
renderer.code = function ({ text, lang }: { text: string; lang?: string }) {
|
||||
if (lang === 'mermaid') {
|
||||
return `<pre class="mermaid">${escapeHtml(text)}</pre>`;
|
||||
}
|
||||
let highlighted: string;
|
||||
try {
|
||||
if (lang && hljs.getLanguage(lang)) {
|
||||
highlighted = hljs.highlight(text, { language: lang, ignoreIllegals: true }).value;
|
||||
} else {
|
||||
highlighted = hljs.highlightAuto(text).value;
|
||||
}
|
||||
} catch {
|
||||
highlighted = escapeHtml(text);
|
||||
}
|
||||
const langLabel = lang ? `<span class="mdxg-lang">${escapeHtml(lang)}</span>` : '';
|
||||
const langClass = lang ? `language-${escapeHtml(lang)}` : '';
|
||||
return `<pre>${langLabel}<button class="mdxg-copy" data-copy="1" type="button" aria-label="copy">copy</button><code class="hljs ${langClass}">${highlighted}</code></pre>`;
|
||||
};
|
||||
if (slugger) {
|
||||
renderer.heading = function ({ tokens, depth, text }: { tokens: unknown[]; depth: number; text: string }) {
|
||||
const self = this as unknown as { parser: { parseInline(tokens: unknown[]): string } };
|
||||
const inner = self.parser.parseInline(tokens);
|
||||
if (depth > 3) {
|
||||
return `<h${depth}>${inner}</h${depth}>`;
|
||||
}
|
||||
const slug = slugger(text);
|
||||
return `<h${depth} id="${slug}"><a class="mdxg-anchor" href="#${slug}" aria-hidden="true">#</a>${inner}</h${depth}>`;
|
||||
};
|
||||
}
|
||||
if (imageBaseUrl) {
|
||||
renderer.image = function ({ href, title, text }: { href: string; title?: string | null; text: string }) {
|
||||
let resolvedHref = href;
|
||||
if (href && !href.startsWith('http://') && !href.startsWith('https://') && !href.startsWith('data:')) {
|
||||
const cleanPath = href.replace(/^\.\//, '');
|
||||
resolvedHref = `${imageBaseUrl}${encodeURIComponent(cleanPath)}`;
|
||||
}
|
||||
const titleAttr = title ? ` title="${title}"` : '';
|
||||
return `<img src="${resolvedHref}" alt="${text}"${titleAttr} style="max-width:100%" />`;
|
||||
};
|
||||
}
|
||||
return renderer;
|
||||
}
|
||||
|
||||
/** embed マーカーでコンテンツを分割する */
|
||||
const EMBED_SPLIT_RE = /\[\[embed:([\w-]+)\]\]/g;
|
||||
|
||||
interface ContentSegment {
|
||||
type: 'markdown' | 'embed';
|
||||
value: string; // markdown: テキスト, embed: refId
|
||||
}
|
||||
|
||||
function splitContentByEmbeds(content: string): ContentSegment[] {
|
||||
const segments: ContentSegment[] = [];
|
||||
let lastIndex = 0;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = EMBED_SPLIT_RE.exec(content)) !== null) {
|
||||
if (match.index > lastIndex) {
|
||||
segments.push({ type: 'markdown', value: content.slice(lastIndex, match.index) });
|
||||
}
|
||||
segments.push({ type: 'embed', value: match[1] });
|
||||
lastIndex = match.index + match[0].length;
|
||||
}
|
||||
if (lastIndex < content.length) {
|
||||
segments.push({ type: 'markdown', value: content.slice(lastIndex) });
|
||||
}
|
||||
return segments;
|
||||
}
|
||||
|
||||
/** 単一の Markdown 断片をレンダリングする内部コンポーネント */
|
||||
function MarkdownSegment({ html }: { html: string }): JSX.Element {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (ref.current) {
|
||||
mermaid.run({ nodes: ref.current.querySelectorAll('.mermaid') }).catch(() => {});
|
||||
}
|
||||
}, [html]);
|
||||
|
||||
return <div ref={ref} dangerouslySetInnerHTML={{ __html: html }} />;
|
||||
}
|
||||
|
||||
const DOMPURIFY_CONFIG = {
|
||||
// `data-output-path`, `role`, `tabindex` added for the output-path
|
||||
// linkifier — defaults already permit `data-*`, but being explicit
|
||||
// guards against future config tightening, same as MarkdownText.
|
||||
ADD_ATTR: ['data-copy', 'aria-hidden', 'aria-label', 'id', 'target', 'rel', 'data-output-path', 'role', 'tabindex'] as string[],
|
||||
};
|
||||
|
||||
interface MarkdownPreviewProps {
|
||||
content: string;
|
||||
imageBaseUrl?: string;
|
||||
taskId?: number;
|
||||
/** true で目次サイドバー + リーダースタイル (MDXG) を有効化。チャット吹き出し等では false 推奨。 */
|
||||
showOutline?: boolean;
|
||||
}
|
||||
|
||||
export function MarkdownPreview({ content, imageBaseUrl, taskId, showOutline = false }: MarkdownPreviewProps): JSX.Element {
|
||||
const truncated = content.slice(0, 100000);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [activeSlug, setActiveSlug] = useState<string>('');
|
||||
|
||||
// 目次抽出 (showOutline=true のときのみ)
|
||||
const outline = useMemo<OutlineEntry[]>(() => {
|
||||
if (!showOutline) return [];
|
||||
try {
|
||||
const parser = new Marked({ gfm: true });
|
||||
return extractOutline(truncated, parser);
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}, [truncated, showOutline]);
|
||||
|
||||
// HTML 生成
|
||||
const segments = useMemo(() => {
|
||||
EMBED_SPLIT_RE.lastIndex = 0;
|
||||
const slugger = showOutline ? buildSlugger() : undefined;
|
||||
const renderer = buildMdRenderer({ imageBaseUrl, slugger });
|
||||
const parser = new Marked({ gfm: true, renderer });
|
||||
|
||||
const hasEmbed = taskId != null && EMBED_SPLIT_RE.test(truncated);
|
||||
EMBED_SPLIT_RE.lastIndex = 0;
|
||||
if (!hasEmbed) {
|
||||
const html = DOMPurify.sanitize(parser.parse(truncated, { async: false }) as string, DOMPURIFY_CONFIG);
|
||||
return [{ type: 'markdown' as const, html }];
|
||||
}
|
||||
return splitContentByEmbeds(truncated).map(seg => {
|
||||
if (seg.type === 'embed') return { type: 'embed' as const, refId: seg.value };
|
||||
const html = DOMPurify.sanitize(parser.parse(seg.value, { async: false }) as string, DOMPURIFY_CONFIG);
|
||||
return { type: 'markdown' as const, html };
|
||||
});
|
||||
}, [truncated, imageBaseUrl, taskId, showOutline]);
|
||||
|
||||
// コピーボタン + アンカーリンクのイベント delegation
|
||||
useEffect(() => {
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
const target = e.target as HTMLElement;
|
||||
const btn = target.closest<HTMLButtonElement>('button.mdxg-copy');
|
||||
if (btn) {
|
||||
const pre = btn.closest('pre');
|
||||
const code = pre?.querySelector('code');
|
||||
if (code) {
|
||||
navigator.clipboard.writeText(code.textContent ?? '').then(() => {
|
||||
btn.classList.add('copied');
|
||||
const original = btn.textContent;
|
||||
btn.textContent = '✓';
|
||||
setTimeout(() => {
|
||||
btn.classList.remove('copied');
|
||||
btn.textContent = original ?? 'copy';
|
||||
}, 1200);
|
||||
}).catch(() => {});
|
||||
}
|
||||
return;
|
||||
}
|
||||
const anchor = target.closest<HTMLAnchorElement>('a.mdxg-anchor');
|
||||
if (anchor) {
|
||||
e.preventDefault();
|
||||
const id = anchor.getAttribute('href')?.slice(1);
|
||||
if (id) {
|
||||
root.querySelector(`#${CSS.escape(id)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
}
|
||||
}
|
||||
};
|
||||
root.addEventListener('click', handler);
|
||||
return () => root.removeEventListener('click', handler);
|
||||
}, [segments]);
|
||||
|
||||
// scroll-spy: 表示中の見出しを active に
|
||||
useEffect(() => {
|
||||
if (!showOutline || outline.length === 0) return;
|
||||
const root = containerRef.current;
|
||||
if (!root) return;
|
||||
const targets = Array.from(root.querySelectorAll<HTMLElement>('h1[id], h2[id], h3[id]'));
|
||||
if (targets.length === 0) return;
|
||||
const observer = new IntersectionObserver(
|
||||
entries => {
|
||||
const visible = entries.filter(e => e.isIntersecting);
|
||||
if (visible.length > 0) {
|
||||
visible.sort((a, b) => a.boundingClientRect.top - b.boundingClientRect.top);
|
||||
setActiveSlug(visible[0].target.id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: '0px 0px -70% 0px', threshold: 0 }
|
||||
);
|
||||
targets.forEach(t => observer.observe(t));
|
||||
if (!activeSlug && targets[0]) setActiveSlug(targets[0].id);
|
||||
return () => observer.disconnect();
|
||||
}, [segments, showOutline, outline.length]);
|
||||
|
||||
const handleOutlineClick = (slug: string) => (e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
const root = containerRef.current;
|
||||
root?.querySelector(`#${CSS.escape(slug)}`)?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||||
};
|
||||
|
||||
const content_el = (
|
||||
<div ref={containerRef} className={`${showOutline ? 'prose prose-slate max-w-none mdxg-reader' : 'prose prose-sm max-w-none'} min-w-0 break-words [&_a]:[overflow-wrap:anywhere] [&_a]:break-all [&_code]:[overflow-wrap:anywhere] [&_pre]:max-w-full [&_pre]:overflow-x-auto`}>
|
||||
{segments.map((seg, i) => {
|
||||
if (seg.type === 'embed') {
|
||||
return <EmbedBlock key={`embed-${seg.refId}-${i}`} refId={seg.refId} taskId={taskId!} />;
|
||||
}
|
||||
return <MarkdownSegment key={`md-${i}`} html={seg.html} />;
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (!showOutline || outline.length < 2) {
|
||||
return content_el;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex gap-4 items-start">
|
||||
<aside className="mdxg-outline hidden md:block flex-shrink-0 sticky top-0 max-h-[68vh] overflow-y-auto pr-2 border-r border-hairline" style={{ width: '200px' }}>
|
||||
<div className="text-2xs font-semibold text-slate-500 uppercase tracking-wide px-2 py-1.5">目次</div>
|
||||
<nav>
|
||||
{outline.map(h => (
|
||||
<a
|
||||
key={h.slug}
|
||||
href={`#${h.slug}`}
|
||||
onClick={handleOutlineClick(h.slug)}
|
||||
className={`depth-${h.depth} ${activeSlug === h.slug ? 'active' : ''}`}
|
||||
title={h.text}
|
||||
>
|
||||
{h.text}
|
||||
</a>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
<div className="flex-1 min-w-0">{content_el}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Print / PDF helpers ---
|
||||
const PRINT_STYLE = `
|
||||
body {
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Hiragino Kaku Gothic ProN", "Yu Gothic", Meiryo, sans-serif;
|
||||
color: #1e293b;
|
||||
line-height: 1.7;
|
||||
max-width: 760px;
|
||||
margin: 32px auto;
|
||||
padding: 0 24px;
|
||||
font-size: 14px;
|
||||
-webkit-print-color-adjust: exact;
|
||||
print-color-adjust: exact;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 { color: #0f172a; font-weight: 600; margin-top: 1.6em; margin-bottom: 0.5em; line-height: 1.3; }
|
||||
h1 { font-size: 1.9em; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.3em; }
|
||||
h2 { font-size: 1.5em; border-bottom: 1px solid #e2e8f0; padding-bottom: 0.2em; }
|
||||
h3 { font-size: 1.25em; }
|
||||
h4 { font-size: 1.05em; }
|
||||
p, ul, ol, blockquote { margin: 0.7em 0; }
|
||||
ul, ol { padding-left: 1.5em; }
|
||||
li { margin: 0.2em 0; }
|
||||
a { color: #2563eb; text-decoration: none; }
|
||||
a:hover { text-decoration: underline; }
|
||||
a.mdxg-anchor { display: none; }
|
||||
a.output-path-link { color: #2563eb; cursor: text; }
|
||||
code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; background: #f1f5f9; padding: 0.1em 0.35em; border-radius: 4px; font-size: 0.9em; }
|
||||
pre { background: #f8fafc; color: #0f172a; padding: 14px 16px; border-radius: 6px; overflow-x: auto; font-size: 0.85em; border: 1px solid #e2e8f0; position: relative; white-space: pre-wrap; word-break: break-word; }
|
||||
pre code { background: transparent; color: inherit; padding: 0; font-size: 1em; }
|
||||
pre .mdxg-copy, pre .mdxg-lang { display: none; }
|
||||
table { border-collapse: collapse; width: 100%; margin: 1em 0; font-size: 0.9em; }
|
||||
th, td { border: 1px solid #e2e8f0; padding: 6px 10px; text-align: left; vertical-align: top; }
|
||||
th { background: #f8fafc; font-weight: 600; }
|
||||
blockquote { border-left: 3px solid #cbd5e1; margin: 1em 0; padding: 0.3em 1em; color: #475569; background: #f8fafc; }
|
||||
img { max-width: 100%; height: auto; display: block; margin: 0.5em 0; }
|
||||
hr { border: 0; border-top: 1px solid #e2e8f0; margin: 1.5em 0; }
|
||||
.mermaid-rendered { margin: 1em 0; text-align: center; }
|
||||
.mermaid-rendered svg { max-width: 100%; height: auto; }
|
||||
.embed-placeholder { border: 1px dashed #cbd5e1; background: #f8fafc; color: #64748b; padding: 8px 12px; border-radius: 6px; margin: 0.7em 0; font-size: 0.85em; }
|
||||
/* hljs minimal light theme */
|
||||
.hljs-comment, .hljs-quote { color: #6a737d; font-style: italic; }
|
||||
.hljs-keyword, .hljs-selector-tag, .hljs-literal, .hljs-section, .hljs-link { color: #d73a49; }
|
||||
.hljs-function .hljs-keyword { color: #d73a49; }
|
||||
.hljs-subst { color: #24292e; }
|
||||
.hljs-string, .hljs-attr, .hljs-template-tag, .hljs-template-variable { color: #032f62; }
|
||||
.hljs-title, .hljs-name, .hljs-type, .hljs-attribute, .hljs-symbol, .hljs-bullet, .hljs-addition, .hljs-variable, .hljs-template-tag, .hljs-template-variable { color: #6f42c1; }
|
||||
.hljs-number, .hljs-meta { color: #005cc5; }
|
||||
.hljs-built_in, .hljs-builtin-name, .hljs-class .hljs-title { color: #e36209; }
|
||||
.hljs-deletion { color: #b31d28; background: #ffeef0; }
|
||||
.hljs-regexp, .hljs-link { color: #032f62; }
|
||||
@media print {
|
||||
body { margin: 0; padding: 12mm; max-width: 100%; }
|
||||
a { color: inherit; text-decoration: none; }
|
||||
a.output-path-link { color: inherit; }
|
||||
pre { background: #f8fafc !important; border: 1px solid #e2e8f0; }
|
||||
h1, h2, h3, h4, h5, h6 { page-break-after: avoid; break-after: avoid; }
|
||||
pre, table, blockquote, .mermaid-rendered, img { page-break-inside: avoid; break-inside: avoid; }
|
||||
}
|
||||
`;
|
||||
|
||||
const EMBED_MARKER_RE = /\[\[embed:([\w-]+)\]\]/g;
|
||||
|
||||
async function buildPrintHtml(content: string, opts: { title: string; imageBaseUrl?: string }): Promise<string> {
|
||||
const truncated = content.slice(0, 100000);
|
||||
const slugger = buildSlugger();
|
||||
const absoluteImageBaseUrl = opts.imageBaseUrl
|
||||
? (opts.imageBaseUrl.startsWith('http') ? opts.imageBaseUrl : `${window.location.origin}${opts.imageBaseUrl}`)
|
||||
: undefined;
|
||||
const renderer = buildMdRenderer({ imageBaseUrl: absoluteImageBaseUrl, slugger });
|
||||
const parser = new Marked({ gfm: true, renderer });
|
||||
const renderedHtml = DOMPurify.sanitize(parser.parse(truncated, { async: false }) as string, DOMPURIFY_CONFIG);
|
||||
|
||||
// Replace [[embed:xxx]] markers with placeholders BEFORE parsing into DOM
|
||||
// (marked passes them through as literal text inside paragraphs).
|
||||
EMBED_MARKER_RE.lastIndex = 0;
|
||||
const withEmbedPlaceholders = renderedHtml.replace(
|
||||
EMBED_MARKER_RE,
|
||||
(_, refId) => `<div class="embed-placeholder">📎 Embedded: <code>${escapeHtml(String(refId))}</code></div>`,
|
||||
);
|
||||
|
||||
// Parse to a temporary DOM so we can swap mermaid <pre> blocks for rendered SVG.
|
||||
const doc = new DOMParser().parseFromString(`<body>${withEmbedPlaceholders}</body>`, 'text/html');
|
||||
const mermaidBlocks = Array.from(doc.body.querySelectorAll<HTMLPreElement>('pre.mermaid'));
|
||||
for (let i = 0; i < mermaidBlocks.length; i++) {
|
||||
const block = mermaidBlocks[i];
|
||||
const source = (block.textContent ?? '').trim();
|
||||
if (!source) continue;
|
||||
try {
|
||||
const id = `mermaid-print-${Date.now()}-${i}`;
|
||||
const { svg } = await mermaid.render(id, source);
|
||||
const wrapper = doc.createElement('div');
|
||||
wrapper.className = 'mermaid-rendered';
|
||||
wrapper.innerHTML = svg;
|
||||
block.replaceWith(wrapper);
|
||||
} catch {
|
||||
// Leave the original <pre> in place if render fails.
|
||||
}
|
||||
}
|
||||
|
||||
const bodyHtml = doc.body.innerHTML;
|
||||
const safeTitle = escapeHtml(opts.title);
|
||||
|
||||
return `<!DOCTYPE html>
|
||||
<html lang="ja">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>${safeTitle}</title>
|
||||
<style>${PRINT_STYLE}</style>
|
||||
</head>
|
||||
<body>
|
||||
${bodyHtml}
|
||||
<script>
|
||||
(function () {
|
||||
function triggerPrint() {
|
||||
try { window.focus(); } catch (e) {}
|
||||
setTimeout(function () { window.print(); }, 150);
|
||||
}
|
||||
function waitForImages(done) {
|
||||
var imgs = Array.prototype.slice.call(document.images);
|
||||
if (imgs.length === 0) { done(); return; }
|
||||
var remaining = imgs.length;
|
||||
var settled = false;
|
||||
function finish() {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
done();
|
||||
}
|
||||
imgs.forEach(function (img) {
|
||||
if (img.complete) {
|
||||
remaining--;
|
||||
if (remaining === 0) finish();
|
||||
return;
|
||||
}
|
||||
var onDone = function () {
|
||||
remaining--;
|
||||
if (remaining === 0) finish();
|
||||
};
|
||||
img.addEventListener('load', onDone, { once: true });
|
||||
img.addEventListener('error', onDone, { once: true });
|
||||
});
|
||||
setTimeout(finish, 3000);
|
||||
}
|
||||
function ready() { waitForImages(triggerPrint); }
|
||||
if (document.readyState === 'complete') ready();
|
||||
else window.addEventListener('load', ready);
|
||||
})();
|
||||
</script>
|
||||
</body>
|
||||
</html>`;
|
||||
}
|
||||
|
||||
// --- JSONL ---
|
||||
function badgeClass(color: 'green' | 'orange' | 'red' | 'gray'): string {
|
||||
if (color === 'green') return 'bg-green-100 text-green-800';
|
||||
if (color === 'orange') return 'bg-amber-100 text-amber-800';
|
||||
if (color === 'red') return 'bg-red-100 text-red-800';
|
||||
return 'bg-slate-100 text-slate-600';
|
||||
}
|
||||
|
||||
function outcomeColor(value: string): 'green' | 'orange' | 'red' | 'gray' {
|
||||
if (value === 'success') return 'green';
|
||||
if (value === 'ssrf_blocked' || value === 'pdf_blocked' || value === 'binary_blocked') return 'orange';
|
||||
if (value === 'error' || value === 'http_error' || value === 'invalid_url') return 'red';
|
||||
return 'gray';
|
||||
}
|
||||
|
||||
function formatCell(key: string, value: unknown): ReactNode {
|
||||
if (value === null || value === undefined) return <span className="text-slate-300">—</span>;
|
||||
|
||||
if (key === 'url' && typeof value === 'string') {
|
||||
return (
|
||||
<a href={value} target="_blank" rel="noopener noreferrer"
|
||||
className="text-blue-600 underline break-all max-w-[300px] block">
|
||||
{value.length > 60 ? `${value.slice(0, 60)}…` : value}
|
||||
</a>
|
||||
);
|
||||
}
|
||||
|
||||
if (key === 'timestamp' && typeof value === 'string') {
|
||||
try {
|
||||
return new Date(value).toLocaleTimeString('ja-JP', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch { return String(value); }
|
||||
}
|
||||
|
||||
if (key === 'outcome' && typeof value === 'string') {
|
||||
const color = outcomeColor(value);
|
||||
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{value}</span>;
|
||||
}
|
||||
|
||||
if (key === 'status') {
|
||||
if (typeof value === 'string') {
|
||||
const color: 'green' | 'red' | 'gray' = value === 'success' ? 'green' : value === 'error' ? 'red' : 'gray';
|
||||
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{value}</span>;
|
||||
}
|
||||
if (typeof value === 'number') {
|
||||
const color: 'green' | 'red' | 'gray' = value >= 200 && value < 300 ? 'green' : value >= 400 ? 'red' : 'gray';
|
||||
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{String(value)}</span>;
|
||||
}
|
||||
}
|
||||
|
||||
if (key === 'exitCode') {
|
||||
const color: 'green' | 'red' = value === 0 ? 'green' : 'red';
|
||||
return <span className={`px-1.5 py-0.5 rounded-full text-[10px] font-bold ${badgeClass(color)}`}>{String(value)}</span>;
|
||||
}
|
||||
|
||||
if (Array.isArray(value)) return value.join(' ');
|
||||
|
||||
const str = typeof value === 'string' ? value : JSON.stringify(value);
|
||||
return str.length > 80 ? `${str.slice(0, 80)}…` : str;
|
||||
}
|
||||
|
||||
function renderJsonl(content: string): JSX.Element {
|
||||
const lines = content.trim().split('\n').filter(Boolean).slice(0, 1000);
|
||||
const records: Record<string, unknown>[] = [];
|
||||
for (const line of lines) {
|
||||
try { records.push(JSON.parse(line) as Record<string, unknown>); }
|
||||
catch { /* skip invalid lines */ }
|
||||
}
|
||||
|
||||
if (records.length === 0) {
|
||||
return <p className="text-slate-400 text-sm">表示できるレコードがありません</p>;
|
||||
}
|
||||
|
||||
const columns = [...new Set(records.flatMap(r => Object.keys(r)))];
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-slate-100">
|
||||
{columns.map(col => (
|
||||
<th key={col} className="px-3 py-2 text-left text-xs font-bold text-slate-600 border-b border-slate-200 whitespace-nowrap">
|
||||
{col}
|
||||
</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{records.map((record, i) => (
|
||||
<tr key={i} className={i % 2 === 0 ? 'bg-white' : 'bg-slate-50'}>
|
||||
{columns.map(col => (
|
||||
<td key={col} className="px-3 py-1.5 border-b border-slate-100 align-top">
|
||||
{formatCell(col, record[col])}
|
||||
</td>
|
||||
))}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// --- FilePreview ---
|
||||
export function FilePreview({ name, content, imageSrc, markdownImageBaseUrl, onClose, taskId, section, filePath, editable }: FilePreviewProps) {
|
||||
const [mode, setMode] = useState<'view' | 'edit'>('view');
|
||||
const [editContent, setEditContent] = useState(content);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [currentContent, setCurrentContent] = useState(content);
|
||||
const [printing, setPrinting] = useState(false);
|
||||
|
||||
const canEdit = editable && taskId != null && section && filePath;
|
||||
const isMarkdownFile = /\.(md|markdown)$/i.test(name);
|
||||
|
||||
const handlePrint = async () => {
|
||||
if (printing) return;
|
||||
setPrinting(true);
|
||||
setError('');
|
||||
try {
|
||||
const html = await buildPrintHtml(currentContent, { title: name, imageBaseUrl: markdownImageBaseUrl });
|
||||
const win = window.open('', '_blank');
|
||||
if (!win) {
|
||||
setError('印刷ウィンドウを開けませんでした。ポップアップブロックを解除してください。');
|
||||
return;
|
||||
}
|
||||
win.document.open();
|
||||
win.document.write(html);
|
||||
win.document.close();
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : '印刷の準備に失敗しました');
|
||||
} finally {
|
||||
setPrinting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!taskId || !section || !filePath) return;
|
||||
setSaving(true);
|
||||
setError('');
|
||||
try {
|
||||
await updateLocalFileContent(taskId, section, filePath, editContent);
|
||||
setCurrentContent(editContent);
|
||||
setMode('view');
|
||||
} catch (err) {
|
||||
setError(err instanceof Error ? err.message : 'Failed to save');
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
const body = (() => {
|
||||
if (mode === 'edit') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2">
|
||||
<textarea
|
||||
className="w-full min-h-[60vh] font-mono text-xs p-3 border border-hairline rounded-md resize-none focus:outline-none focus:ring-2 focus:ring-accent-ring transition-shadow"
|
||||
value={editContent}
|
||||
onChange={e => setEditContent(e.target.value)}
|
||||
/>
|
||||
{error && <p className="text-red-600 text-xs">{error}</p>}
|
||||
<div className="flex gap-2 justify-end">
|
||||
<button
|
||||
onClick={() => { setMode('view'); setError(''); }}
|
||||
className="px-3 h-8 text-xs rounded-md border border-hairline bg-white text-slate-700 hover:bg-surface transition-colors"
|
||||
>
|
||||
キャンセル
|
||||
</button>
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving}
|
||||
className="px-3 h-8 text-xs font-semibold rounded-md bg-accent text-accent-fg hover:bg-accent-deep disabled:opacity-50 transition-colors"
|
||||
>
|
||||
{saving ? '保存中...' : '保存'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// view mode
|
||||
if (imageSrc) {
|
||||
if (/\.html?$/i.test(name)) {
|
||||
return (
|
||||
<iframe
|
||||
src={imageSrc}
|
||||
sandbox="allow-scripts allow-same-origin"
|
||||
className="w-full rounded-lg border-0"
|
||||
style={{ height: '72vh' }}
|
||||
title={name}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (/\.pdf$/i.test(name)) {
|
||||
return (
|
||||
<embed
|
||||
src={imageSrc}
|
||||
type="application/pdf"
|
||||
className="w-full rounded-lg"
|
||||
style={{ height: '72vh' }}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex justify-center">
|
||||
<img src={imageSrc} alt={name} className="max-w-full max-h-[72vh] object-contain rounded-lg" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (/\.(md|markdown)$/i.test(name)) return <MarkdownPreview content={currentContent} imageBaseUrl={markdownImageBaseUrl} showOutline taskId={taskId} />;
|
||||
if (/\.csv$/i.test(name)) return renderCsv(currentContent);
|
||||
if (/\.jsonl$/i.test(name)) return renderJsonl(currentContent);
|
||||
return <pre className="text-xs whitespace-pre-wrap break-all">{currentContent.slice(0, 100000)}</pre>;
|
||||
})();
|
||||
|
||||
const isMarkdown = /\.(md|markdown)$/i.test(name);
|
||||
const modalWidth = isMarkdown ? 'min(1400px, 96vw)' : 'min(1000px, 94vw)';
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 bg-black/40 flex items-center justify-center z-50 p-[env(safe-area-inset-top)_env(safe-area-inset-right)_env(safe-area-inset-bottom)_env(safe-area-inset-left)]" onClick={onClose}>
|
||||
<div
|
||||
className="bg-white rounded-md border border-hairline shadow-md flex flex-col overflow-hidden"
|
||||
style={{ width: modalWidth, maxHeight: 'min(90vh, calc(100dvh - env(safe-area-inset-top, 0px) - env(safe-area-inset-bottom, 0px) - 24px))' }}
|
||||
onClick={e => e.stopPropagation()}
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
>
|
||||
<div className="flex justify-between items-center px-4 py-2.5 border-b border-hairline flex-shrink-0 sticky top-0 bg-white z-10 gap-2">
|
||||
<div className="font-mono text-xs text-slate-700 truncate" title={name}>{name}</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{isMarkdownFile && mode === 'view' && (
|
||||
<button
|
||||
onClick={handlePrint}
|
||||
disabled={printing}
|
||||
title="ブラウザの印刷ダイアログから PDF として保存または印刷"
|
||||
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-hairline bg-white text-slate-700 hover:bg-surface disabled:opacity-50 transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M4 5V2h8v3M4 11H2.5A1.5 1.5 0 0 1 1 9.5v-3A1.5 1.5 0 0 1 2.5 5h11A1.5 1.5 0 0 1 15 6.5v3a1.5 1.5 0 0 1-1.5 1.5H12M4 9.5h8v4.5H4z" />
|
||||
</svg>
|
||||
{printing ? '準備中...' : 'PDF / 印刷'}
|
||||
</button>
|
||||
)}
|
||||
{canEdit && mode === 'view' && (
|
||||
<button
|
||||
onClick={() => { setEditContent(currentContent); setMode('edit'); }}
|
||||
className="inline-flex items-center gap-1 px-2.5 h-7 text-2xs font-medium rounded-md border border-hairline bg-white text-slate-700 hover:bg-surface transition-colors"
|
||||
>
|
||||
<svg className="w-3 h-3" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" strokeLinejoin="round">
|
||||
<path d="M11.5 2.5l2 2L5 13l-2.5.5L3 11l8.5-8.5z" />
|
||||
</svg>
|
||||
編集
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="inline-flex items-center justify-center w-7 h-7 rounded-md text-slate-400 hover:text-slate-700 hover:bg-surface-2 transition-colors"
|
||||
aria-label="プレビューを閉じる"
|
||||
>
|
||||
<svg className="w-3.5 h-3.5" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.75" strokeLinecap="round">
|
||||
<path d="M4 4l8 8M12 4l-8 8" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 overflow-auto flex-1">
|
||||
{mode === 'view' && error && (
|
||||
<div className="mb-2 px-3 py-2 bg-red-50 border border-red-200 text-red-700 text-xs rounded">{error}</div>
|
||||
)}
|
||||
{body}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user