Skip to content

Commit 9f8792a

Browse files
authored
Merge pull request #1056 from swfz/feature/add-ansi-text-display
feature/add ansi text display
2 parents 8447280 + 835cb32 commit 9f8792a

2 files changed

Lines changed: 236 additions & 0 deletions

File tree

pages/ansi-text-display/index.tsx

Lines changed: 227 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,227 @@
1+
import type { NextPage } from 'next';
2+
import Head from 'next/head';
3+
import React, { useState } from 'react';
4+
5+
const ANSITextDisplay: NextPage = () => {
6+
const [text, setText] = useState('');
7+
const [format, setFormat] = useState('raw'); // raw, json, yaml
8+
9+
// Solarized Darkのカラーパレット
10+
const solarizedColors = {
11+
base03: '#002b36',
12+
base02: '#073642',
13+
base01: '#586e75',
14+
base00: '#657b83',
15+
base0: '#839496',
16+
base1: '#93a1a1',
17+
base2: '#eee8d5',
18+
base3: '#fdf6e3',
19+
yellow: '#b58900',
20+
orange: '#cb4b16',
21+
red: '#dc322f',
22+
magenta: '#d33682',
23+
violet: '#6c71c4',
24+
blue: '#268bd2',
25+
cyan: '#2aa198',
26+
green: '#859900',
27+
};
28+
29+
const preprocessText = (input: string, format: string) => {
30+
switch (format) {
31+
case 'json':
32+
return input.replace(/\\u001b\[/g, '\u001b[').replace(/\\n/g, '\n');
33+
case 'yaml':
34+
return input.replace(/\\e\[/g, '\u001b[').replace(/\\n/g, '\n');
35+
case 'raw':
36+
default:
37+
return input.replace(/\[(\d+(?:;\d+)*m)/g, '\u001b[$1').replace(/\\n/g, '\n');
38+
}
39+
};
40+
41+
// フォーマット変換関数
42+
const convertFormat = {
43+
toRaw: (text: string) =>
44+
text
45+
.replace(/\\u001b\[/g, '[')
46+
.replace(/\\x1b\[/g, '[')
47+
.replace(/\\e\[/g, '['),
48+
toJSON: (text: string) =>
49+
text
50+
.replace(/\\x1b\[/g, '\\u001b[')
51+
.replace(/\\e\[/g, '\\u001b[')
52+
.replace(/(?<!\\)\[/g, '\\u001b['),
53+
toYAML: (text: string) =>
54+
text
55+
.replace(/\\u001b\[/g, '\\e[')
56+
.replace(/\\x1b\[/g, '\\e[')
57+
.replace(/(?<!\\)\[/g, '\\e['),
58+
};
59+
60+
const parseANSI = (input: string) => {
61+
// フォーマットに応じて前処理
62+
const processedText = preprocessText(input, format);
63+
64+
// 基本的なカラーコードマッピング
65+
const basicColorMap: { [key: string]: string } = {
66+
'30': solarizedColors.base02,
67+
'31': solarizedColors.red,
68+
'32': solarizedColors.green,
69+
'33': solarizedColors.yellow,
70+
'34': solarizedColors.blue,
71+
'35': solarizedColors.magenta,
72+
'36': solarizedColors.cyan,
73+
'37': solarizedColors.base2,
74+
'90': solarizedColors.base03,
75+
'91': solarizedColors.orange,
76+
'92': solarizedColors.base01,
77+
'93': solarizedColors.base00,
78+
'94': solarizedColors.base0,
79+
'95': solarizedColors.violet,
80+
'96': solarizedColors.base1,
81+
'97': solarizedColors.base3,
82+
};
83+
84+
const parts = processedText.split(/(\u001b\[\d+(?:;\d+)*m)/);
85+
let currentColor = solarizedColors.base0;
86+
let currentBg = 'transparent';
87+
let isBold = false;
88+
let isDim = false;
89+
90+
return parts
91+
.map((part, index) => {
92+
if (part.startsWith('\u001b[')) {
93+
const codes =
94+
part
95+
.match(/\u001b\[([^\u001b]*)/)?.[1]
96+
.toString()
97+
.replace('m', '')
98+
.split(';') || [];
99+
100+
for (const code of codes) {
101+
if (code === '0') {
102+
currentColor = solarizedColors.base0;
103+
currentBg = 'transparent';
104+
isBold = false;
105+
isDim = false;
106+
} else if (code === '1') {
107+
isBold = true;
108+
} else if (code === '2') {
109+
isDim = true;
110+
} else if (code === '22') {
111+
isBold = false;
112+
isDim = false;
113+
} else if (basicColorMap[code]) {
114+
currentColor = basicColorMap[code];
115+
}
116+
}
117+
return null;
118+
}
119+
120+
const lines = part.split('\n');
121+
return lines.map((line, lineIndex) => (
122+
<React.Fragment key={`${index}-${lineIndex}`}>
123+
{lineIndex > 0 && <br />}
124+
<span
125+
style={{
126+
color: currentColor,
127+
backgroundColor: currentBg,
128+
fontWeight: isBold ? 'bold' : 'normal',
129+
opacity: isDim ? 0.5 : 1,
130+
fontFamily: 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace',
131+
}}
132+
className="whitespace-pre"
133+
>
134+
{line}
135+
</span>
136+
</React.Fragment>
137+
));
138+
})
139+
.filter(Boolean);
140+
};
141+
142+
return (
143+
<>
144+
<Head>
145+
<title>Ansi Text Display</title>
146+
</Head>
147+
<div className="divide-y divide-gray-300">
148+
<div>
149+
<h1>Ansi Text Display</h1>
150+
</div>
151+
<div className="p-3">
152+
<div>
153+
<div>Ansiエスケープ文字が含まれた文字列をカラー付きで表示するためのフォーム</div>
154+
<div>主にAnsible実行失敗時の結果などで使える</div>
155+
</div>
156+
</div>
157+
<div className="w-full max-w-4xl mx-auto p-4">
158+
<div className="flex gap-4 mb-4">
159+
<details>
160+
<summary className="px-3 py-1">フォーマット変換</summary>
161+
<div className="flex gap-4">
162+
<button
163+
onClick={() => setText(convertFormat.toRaw(text))}
164+
className="px-3 py-1 rounded hover:opacity-80"
165+
style={{ backgroundColor: solarizedColors.blue, color: solarizedColors.base3 }}
166+
>
167+
生形式に変換
168+
</button>
169+
<button
170+
onClick={() => setText(convertFormat.toJSON(text))}
171+
className="px-3 py-1 rounded hover:opacity-80"
172+
style={{ backgroundColor: solarizedColors.cyan, color: solarizedColors.base3 }}
173+
>
174+
JSON形式に変換
175+
</button>
176+
<button
177+
onClick={() => setText(convertFormat.toYAML(text))}
178+
className="px-3 py-1 rounded hover:opacity-80"
179+
style={{ backgroundColor: solarizedColors.green, color: solarizedColors.base3 }}
180+
>
181+
YAML形式に変換
182+
</button>
183+
</div>
184+
</details>
185+
</div>
186+
187+
<textarea
188+
value={text}
189+
onChange={(e) => setText(e.target.value)}
190+
className="w-full p-2 rounded mb-4 font-mono"
191+
rows={10}
192+
cols={100}
193+
placeholder="エスケープシーケンス付きのテキストを入力してください..."
194+
/>
195+
196+
<div className="mb-4">
197+
<div className="flex items-center gap-4 mb-2">
198+
<label className="text-sm">入力形式:</label>
199+
<select value={format} onChange={(e) => setFormat(e.target.value)} className="px-2 py-1 rounded text-sm">
200+
<option value="raw">生のエスケープシーケンス ([31m)</option>
201+
<option value="json">JSONエスケープ形式 (\u001b)</option>
202+
<option value="yaml">YAMLエスケープ形式 (\e)</option>
203+
</select>
204+
</div>
205+
<p className="text-sm">
206+
現在の形式:{' '}
207+
{format === 'raw' ? '[31mText[0m' : format === 'json' ? '\\u001b[31mText\\u001b[0m' : '\\e[31mText\\e[0m'}
208+
</p>
209+
</div>
210+
211+
<div>結果</div>
212+
<div
213+
className="rounded p-4 min-h-[200px]"
214+
style={{
215+
backgroundColor: solarizedColors.base02,
216+
border: `1px solid ${solarizedColors.base01}`,
217+
}}
218+
>
219+
{parseANSI(text)}
220+
</div>
221+
</div>
222+
</div>
223+
</>
224+
);
225+
};
226+
227+
export default ANSITextDisplay;

src/components/layout.tsx

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import Link from 'next/link';
22
import { useState, ReactNode, MouseEvent, useEffect } from 'react';
33
import { useMedia } from 'react-use';
44
import { ClockIcon, AdjustmentsIcon, GridIcon, ChevronDoubleIcon } from './icon';
5+
import { PaintbrushIcon } from '@primer/octicons-react';
56

67
function Layout({ children }: { children: ReactNode }) {
78
const isWide = useMedia('(min-width: 640px)', false);
@@ -63,6 +64,14 @@ function Layout({ children }: { children: ReactNode }) {
6364
</span>
6465
</Link>
6566
</li>
67+
<li className={'inline-block h-8 text-xs hover:text-pink-800 sm:text-base'}>
68+
<Link href="/ansi-text-display" passHref>
69+
<span className="flex cursor-pointer items-center">
70+
<PaintbrushIcon size={24} />
71+
<span className={isOpen ? '' : 'hidden'}>Ansi Text Display</span>
72+
</span>
73+
</Link>
74+
</li>
6675
</ul>
6776
<div className="absolute bottom-0"></div>
6877
</div>

0 commit comments

Comments
 (0)