Skip to content

Commit 8e369f9

Browse files
committed
シンタックスハイライト実装、コピペ対応など
1 parent aa4b9b2 commit 8e369f9

File tree

5 files changed

+270
-101
lines changed

5 files changed

+270
-101
lines changed

app/terminal/highlight.ts

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
import Prism from "prismjs";
2+
import chalk from "chalk";
3+
// Python言語定義をインポート
4+
import "prismjs/components/prism-python";
5+
6+
const nothing = (text: string): string => text;
7+
8+
// PrismのトークンクラスとANSIコードをマッピング
9+
const prismToAnsi: Record<string, (text: string) => string> = {
10+
keyword: chalk.bold.cyan,
11+
function: chalk.bold.yellow,
12+
string: chalk.green,
13+
number: chalk.yellow,
14+
boolean: chalk.yellow,
15+
comment: chalk.dim,
16+
operator: chalk.magenta,
17+
punctuation: nothing,
18+
"class-name": chalk.bold.blue,
19+
// 必要に応じて他のトークンも追加
20+
};
21+
22+
/**
23+
* Prism.jsでハイライトされたHTMLを解析し、ANSIエスケープシーケンスを含む文字列に変換する
24+
* @param {string} code ハイライト対象のPythonコード
25+
* @returns {string} ANSIで色付けされた文字列
26+
*/
27+
export function highlightCodeToAnsi(code: string, language: string): string {
28+
// Prismでハイライト処理を行い、HTML文字列を取得
29+
const highlightedHtml = Prism.highlight(
30+
code,
31+
Prism.languages[language],
32+
language
33+
);
34+
35+
// 一時的なDOM要素を作成してパース
36+
const tempDiv = document.createElement("div");
37+
tempDiv.innerHTML = highlightedHtml;
38+
39+
// DOMノードを再帰的にトラバースしてANSI文字列を構築
40+
function traverseNodes(node: Node): string {
41+
// テキストノードの場合、そのままテキストを追加
42+
if (node.nodeType === Node.TEXT_NODE) {
43+
return node.textContent || "";
44+
}
45+
46+
// 要素ノード(<span>)の場合
47+
if (node.nodeType === Node.ELEMENT_NODE) {
48+
const tokenType = (node as Element).className.replace("token ", "");
49+
if (!(tokenType in prismToAnsi)) {
50+
console.warn(`Unknown token type: ${tokenType}`);
51+
}
52+
const withHighlight: (text: string) => string =
53+
prismToAnsi[tokenType] ?? nothing;
54+
55+
// 子ノードを再帰的に処理
56+
return withHighlight(
57+
Array.from(node.childNodes).reduce(
58+
(acc, child) => acc + traverseNodes(child),
59+
""
60+
)
61+
);
62+
}
63+
64+
return "";
65+
}
66+
67+
return Array.from(tempDiv.childNodes).reduce(
68+
(acc, child) => acc + traverseNodes(child),
69+
""
70+
);
71+
}

app/terminal/python/page.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,8 @@ export default function PythonPage() {
1212
initMessage="Welcome to Pyodide Terminal!"
1313
prompt=">>> "
1414
promptMore="... "
15+
language="python"
16+
tabSize={4}
1517
sendCommand={runPython}
1618
checkSyntax={checkSyntax}
1719
/>

app/terminal/terminal.tsx

Lines changed: 163 additions & 80 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
"use client";
22

3-
import React, { useEffect, useRef, useState } from "react";
3+
import React, { useCallback, useEffect, useRef, useState } from "react";
44
import { Terminal } from "@xterm/xterm";
55
import { FitAddon } from "@xterm/addon-fit";
66
import "@xterm/xterm/css/xterm.css";
7+
import { highlightCodeToAnsi } from "./highlight";
8+
import chalk from "chalk";
79

810
export interface TerminalOutput {
911
type: "stdout" | "stderr" | "error" | "return"; // 出力の種類
@@ -16,6 +18,8 @@ interface TerminalComponentProps {
1618
initMessage: string; // ターミナル初期化時のメッセージ
1719
prompt: string; // プロンプト文字列
1820
promptMore?: string;
21+
language?: string;
22+
tabSize: number;
1923
// コマンド実行時のコールバック関数
2024
sendCommand: (command: string) => Promise<TerminalOutput[]>;
2125
// 構文チェックのコールバック関数
@@ -26,28 +30,10 @@ export function TerminalComponent(props: TerminalComponentProps) {
2630
const terminalRef = useRef<HTMLDivElement>(null!);
2731
const terminalInstanceRef = useRef<Terminal | null>(null);
2832
const [termReady, setTermReady] = useState<boolean>(false);
29-
const inputBuffer = useRef<string[]>([""]);
33+
const inputBuffer = useRef<string[]>([]);
3034

31-
const initMessage = useRef<string>(null!);
32-
initMessage.current = props.initMessage;
33-
const prompt = useRef<string>(null!);
34-
prompt.current = props.prompt;
35-
const promptMore = useRef<string>(null!);
36-
promptMore.current = props.promptMore || props.prompt;
37-
const sendCommand = useRef<(command: string) => Promise<TerminalOutput[]>>(
38-
null!
39-
);
40-
sendCommand.current = props.sendCommand;
41-
const checkSyntax = useRef<(code: string) => Promise<SyntaxStatus>>(null!);
42-
checkSyntax.current = props.checkSyntax || (async () => "complete");
43-
44-
useEffect(() => {
45-
if (terminalInstanceRef.current && termReady && props.ready) {
46-
// 初期メッセージとプロンプトを表示
47-
terminalInstanceRef.current.writeln(initMessage.current);
48-
terminalInstanceRef.current.write(prompt.current);
49-
}
50-
}, [props.ready, termReady]);
35+
const { prompt, promptMore, language, tabSize, sendCommand, checkSyntax } =
36+
props;
5137

5238
// ターミナルの初期化処理
5339
useEffect(() => {
@@ -56,6 +42,7 @@ export function TerminalComponent(props: TerminalComponentProps) {
5642
convertEol: true,
5743
});
5844
terminalInstanceRef.current = term;
45+
initDone.current = false;
5946

6047
const fitAddon = new FitAddon();
6148
term.loadAddon(fitAddon);
@@ -66,72 +53,168 @@ export function TerminalComponent(props: TerminalComponentProps) {
6653
// TODO: loadingメッセージ
6754
// TODO: ターミナルのサイズ変更に対応する
6855

69-
const onOutput = (outputs: TerminalOutput[]) => {
70-
for (const output of outputs) {
71-
// 出力内容に応じて色を変える
72-
const message = String(output.message).replace(/\n/g, "\r\n");
73-
switch (output.type) {
74-
case "stderr":
75-
case "error":
76-
term.writeln(`\x1b[1;31m${message}\x1b[0m`);
77-
break;
78-
default:
79-
term.writeln(message);
80-
break;
56+
return () => {
57+
term.dispose();
58+
terminalInstanceRef.current = null;
59+
};
60+
}, []);
61+
62+
// bufferを更新し、画面に描画する
63+
const updateBuffer = useCallback(
64+
(newBuffer: () => string[]) => {
65+
if (terminalInstanceRef.current) {
66+
// カーソル非表示
67+
terminalInstanceRef.current.write("\x1b[?25l");
68+
// バッファの行数分カーソルを戻す
69+
if (inputBuffer.current.length >= 2) {
70+
terminalInstanceRef.current.write(
71+
`\x1b[${inputBuffer.current.length - 1}A`
72+
);
73+
}
74+
terminalInstanceRef.current.write("\r");
75+
// バッファの内容をクリア
76+
terminalInstanceRef.current.write("\x1b[0J");
77+
// 新しいバッファの内容を表示
78+
inputBuffer.current = newBuffer();
79+
for (let i = 0; i < inputBuffer.current.length; i++) {
80+
terminalInstanceRef.current.write(
81+
i === 0 ? prompt : promptMore || prompt
82+
);
83+
if (language) {
84+
terminalInstanceRef.current.write(
85+
highlightCodeToAnsi(inputBuffer.current[i], language)
86+
);
87+
} else {
88+
terminalInstanceRef.current.write(inputBuffer.current[i]);
89+
}
90+
if (i < inputBuffer.current.length - 1) {
91+
terminalInstanceRef.current.writeln("");
92+
}
8193
}
94+
// カーソルを表示
95+
terminalInstanceRef.current.write("\x1b[?25h");
8296
}
83-
// 出力が終わったらプロンプトを表示
84-
term.write(prompt.current);
85-
};
97+
},
98+
[prompt, promptMore, language]
99+
);
86100

87-
// キー入力のハンドリング
88-
const onDataHandler = term.onData(async (key) => {
89-
const code = key.charCodeAt(0);
101+
const initDone = useRef<boolean>(false);
102+
useEffect(() => {
103+
if (
104+
terminalInstanceRef.current &&
105+
termReady &&
106+
props.ready &&
107+
!initDone.current
108+
) {
109+
// 初期メッセージとプロンプトを表示
110+
terminalInstanceRef.current.writeln(props.initMessage);
111+
initDone.current = true;
112+
updateBuffer(() => [""]);
113+
}
114+
}, [props.ready, termReady, props.initMessage, updateBuffer]);
90115

91-
// inputBufferは必ず1行以上ある状態にする
92-
if (code === 13) {
93-
// Enter
94-
const hasContent =
95-
inputBuffer.current[inputBuffer.current.length - 1].trim().length > 0;
96-
const status = await checkSyntax.current(
97-
inputBuffer.current.join("\n")
98-
);
99-
if (
100-
(inputBuffer.current.length === 1 && status === "incomplete") ||
101-
(inputBuffer.current.length >= 2 && hasContent)
102-
) {
103-
// 次の行に続く
104-
term.writeln("");
105-
term.write(promptMore.current);
106-
inputBuffer.current.push("");
107-
} else {
108-
// 実行
109-
term.writeln("");
110-
const outputs = await sendCommand.current(
111-
inputBuffer.current.join("\n").trim()
112-
);
113-
onOutput(outputs);
114-
inputBuffer.current = [""];
116+
// ランタイムからの出力を処理し、bufferをリセット
117+
const onOutput = useCallback(
118+
(outputs: TerminalOutput[]) => {
119+
if (terminalInstanceRef.current) {
120+
for (const output of outputs) {
121+
// 出力内容に応じて色を変える
122+
const message = String(output.message).replace(/\n/g, "\r\n");
123+
switch (output.type) {
124+
case "stderr":
125+
case "error":
126+
terminalInstanceRef.current.writeln(chalk.red(message));
127+
break;
128+
default:
129+
terminalInstanceRef.current.writeln(message);
130+
break;
131+
}
115132
}
116-
} else if (code === 127) {
117-
// Backspace
118-
if (inputBuffer.current[inputBuffer.current.length - 1].length > 0) {
119-
term.write("\b \b");
120-
inputBuffer.current[inputBuffer.current.length - 1] =
121-
inputBuffer.current[inputBuffer.current.length - 1].slice(0, -1);
133+
// 出力が終わったらプロンプトを表示
134+
updateBuffer(() => [""]);
135+
}
136+
},
137+
[updateBuffer]
138+
);
139+
140+
const keyHandler = useCallback(
141+
async (key: string) => {
142+
if (terminalInstanceRef.current) {
143+
for (let i = 0; i < key.length; i++) {
144+
const code = key.charCodeAt(i);
145+
const isLastChar = i === key.length - 1;
146+
147+
// inputBufferは必ず1行以上ある状態にする
148+
if (code === 13) {
149+
// Enter
150+
const hasContent =
151+
inputBuffer.current[inputBuffer.current.length - 1].trim()
152+
.length > 0;
153+
const status = checkSyntax
154+
? await checkSyntax(inputBuffer.current.join("\n"))
155+
: "complete";
156+
if (
157+
(inputBuffer.current.length === 1 && status === "incomplete") ||
158+
(inputBuffer.current.length >= 2 && hasContent) ||
159+
!isLastChar
160+
) {
161+
// 次の行に続く
162+
updateBuffer(() => [...inputBuffer.current, ""]);
163+
} else {
164+
// 実行
165+
terminalInstanceRef.current.writeln("");
166+
const command = inputBuffer.current.join("\n").trim();
167+
inputBuffer.current = [];
168+
const outputs = await sendCommand(command);
169+
onOutput(outputs);
170+
}
171+
} else if (code === 127) {
172+
// Backspace
173+
if (
174+
inputBuffer.current[inputBuffer.current.length - 1].length > 0
175+
) {
176+
updateBuffer(() => {
177+
const newBuffer = [...inputBuffer.current];
178+
newBuffer[newBuffer.length - 1] = newBuffer[
179+
newBuffer.length - 1
180+
].slice(0, -1);
181+
return newBuffer;
182+
});
183+
}
184+
} else if (code === 9) {
185+
// Tab
186+
// タブをスペースに変換
187+
const spaces = " ".repeat(tabSize);
188+
updateBuffer(() => {
189+
const newBuffer = [...inputBuffer.current];
190+
// 最後の行にスペースを追加
191+
newBuffer[newBuffer.length - 1] += spaces;
192+
return newBuffer;
193+
});
194+
} else if (code >= 32) {
195+
updateBuffer(() => {
196+
const newBuffer = [...inputBuffer.current];
197+
// 最後の行にキーを追加
198+
newBuffer[newBuffer.length - 1] += key[i];
199+
return newBuffer;
200+
});
201+
}
122202
}
123-
} else if (code >= 32) {
124-
inputBuffer.current[inputBuffer.current.length - 1] += key;
125-
term.write(key);
126203
}
127-
});
204+
},
205+
[updateBuffer, sendCommand, onOutput, checkSyntax, tabSize]
206+
);
207+
useEffect(() => {
208+
if (terminalInstanceRef.current && termReady && props.ready) {
209+
// キー入力のハンドリング
210+
const onDataHandler = terminalInstanceRef.current.onData(keyHandler);
128211

129-
// アンマウント時のクリーンアップ
130-
return () => {
131-
onDataHandler.dispose();
132-
term.dispose();
133-
};
134-
}, [initMessage, prompt, promptMore]);
212+
// アンマウント時のクリーンアップ
213+
return () => {
214+
onDataHandler.dispose();
215+
};
216+
}
217+
}, [keyHandler, termReady, props.ready]);
135218

136219
return <div ref={terminalRef} style={{ width: "100%", height: "400px" }} />;
137220
}

0 commit comments

Comments
 (0)