feat: init

This commit is contained in:
Simon
2025-09-29 16:33:15 +08:00
parent e8041e0582
commit 847620b5e8
98 changed files with 20166 additions and 0 deletions

View File

@@ -0,0 +1,369 @@
/**
* JS 调试台,适合在文档中直接让用户运行代码,并且实时查看运行结果
*/
/* eslint-disable @typescript-eslint/no-unsafe-member-access */
/* eslint-disable @typescript-eslint/no-base-to-string */
import { KeyboardEvent, forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
import HighlightSyntax from './HighlightSyntax'
import styles from './JSConsole.module.css'
// 全局console拦截管理器
class ConsoleInterceptor {
private static instance: ConsoleInterceptor
private subscribers = new Set<(type: string, args: unknown[]) => void>()
private originalConsole: {
log: typeof console.log
warn: typeof console.warn
error: typeof console.error
}
private isIntercepting = false
private constructor() {
this.originalConsole = {
log: console.log.bind(console),
warn: console.warn.bind(console),
error: console.error.bind(console),
}
}
static getInstance() {
// eslint-disable-next-line @typescript-eslint/no-unnecessary-condition
if (!ConsoleInterceptor.instance) {
ConsoleInterceptor.instance = new ConsoleInterceptor()
}
return ConsoleInterceptor.instance
}
subscribe(callback: (type: string, args: unknown[]) => void) {
this.subscribers.add(callback)
this.startIntercepting()
}
unsubscribe(callback: (type: string, args: unknown[]) => void) {
this.subscribers.delete(callback)
if (this.subscribers.size === 0) {
this.stopIntercepting()
}
}
private startIntercepting() {
if (this.isIntercepting) return
this.isIntercepting = true
console.log = (...args: unknown[]) => {
this.originalConsole.log(...args)
this.notifySubscribers('log', args)
}
console.warn = (...args: unknown[]) => {
this.originalConsole.warn(...args)
this.notifySubscribers('warn', args)
}
console.error = (...args: unknown[]) => {
this.originalConsole.error(...args)
this.notifySubscribers('error', args)
}
}
private stopIntercepting() {
if (!this.isIntercepting) return
this.isIntercepting = false
console.log = this.originalConsole.log
console.warn = this.originalConsole.warn
console.error = this.originalConsole.error
}
private notifySubscribers(type: string, args: unknown[]) {
this.subscribers.forEach((callback) => {
callback(type, args)
})
}
}
interface JSConsoleProps {
context?: Record<string, unknown>
height?: string
onExecute?: (code: string, result: unknown) => void
placeholder?: string
}
export interface JSConsoleRef {
executeCode: (code: string) => Promise<unknown>
clear: () => void
appendOutput: (content: string) => void
}
interface OutputItem {
type: 'input' | 'output' | 'error' | 'log'
content: string
timestamp: number
}
const JSConsole = forwardRef<JSConsoleRef, JSConsoleProps>(
(
{ context = {}, height = '400px', onExecute, placeholder = 'Enter JavaScript code...' },
ref
) => {
const [input, setInput] = useState('')
const [outputs, setOutputs] = useState<OutputItem[]>([])
const [isExecuting, setIsExecuting] = useState(false)
const inputRef = useRef<HTMLTextAreaElement>(null)
const outputRef = useRef<HTMLDivElement>(null)
// 持久的执行上下文,用于多轮对话共享作用域
const executionContextRef = useRef<Record<string, unknown>>({})
// 格式化结果
const formatResult = (value: unknown): string => {
if (value === null) return 'null'
if (value === undefined) return 'undefined'
if (typeof value === 'string') return `"${value}"`
if (typeof value === 'function') return `[Function: ${value.name || 'anonymous'}]`
if (typeof value === 'object') {
try {
return JSON.stringify(value, null, 2)
} catch {
return value.toString()
}
}
return String(value)
}
// 全局console拦截处理
useEffect(() => {
const interceptor = ConsoleInterceptor.getInstance()
const handleGlobalConsole = (type: string, args: unknown[]) => {
const content = args.map((arg) => formatResult(arg)).join(' ')
const outputItem: OutputItem = {
type: type as any,
content: content,
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, outputItem])
// 自动滚动到底部
setTimeout(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}, 0)
}
interceptor.subscribe(handleGlobalConsole)
return () => {
interceptor.unsubscribe(handleGlobalConsole)
}
}, [])
// 执行代码
const executeCode = async (code: string): Promise<unknown> => {
if (!code.trim()) return
setIsExecuting(true)
// 添加输入到输出
const inputItem: OutputItem = {
type: 'input',
content: code,
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, inputItem])
try {
// 创建异步函数以支持 await
const AsyncFunction = Object.getPrototypeOf(async function () {}).constructor
// 合并外部上下文和持久执行上下文
const allContext = { ...context, ...executionContextRef.current }
const contextKeys = Object.keys(allContext)
const contextValues = Object.values(allContext)
// 注入 console.log 重定向
const logs: string[] = []
const mockConsole = {
log: (...args: unknown[]) => {
logs.push(args.map((arg) => formatResult(arg)).join(' '))
},
error: (...args: unknown[]) => {
logs.push('ERROR: ' + args.map((arg) => formatResult(arg)).join(' '))
},
warn: (...args: unknown[]) => {
logs.push('WARN: ' + args.map((arg) => formatResult(arg)).join(' '))
},
}
// 检测代码是否是表达式还是语句
const trimmedCode = code.trim()
const isExpression =
!trimmedCode.includes(';') &&
!trimmedCode.startsWith('const ') &&
!trimmedCode.startsWith('let ') &&
!trimmedCode.startsWith('var ') &&
!trimmedCode.startsWith('function ') &&
!trimmedCode.startsWith('class ') &&
!trimmedCode.startsWith('if ') &&
!trimmedCode.startsWith('for ') &&
!trimmedCode.startsWith('while ') &&
!trimmedCode.startsWith('try ') &&
!trimmedCode.startsWith('{') &&
!trimmedCode.includes('\n')
// 如果是表达式,自动添加 return
const codeToExecute = isExpression ? `return ${code}` : code
const wrappedCode = `
return (async function() {
${codeToExecute}
})();
`
// 执行代码
const func = new AsyncFunction('console', ...contextKeys, wrappedCode)
const result = await func(mockConsole, ...contextValues)
// 添加 console.log 输出
if (logs.length > 0) {
const logItem: OutputItem = {
type: 'log',
content: logs.join('\n'),
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, logItem])
}
// 总是添加执行结果输出(包括 undefined
const outputItem: OutputItem = {
type: 'output',
content: formatResult(result),
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, outputItem])
onExecute?.(code, result)
return result
} catch (error) {
const errorItem: OutputItem = {
type: 'error',
content: error instanceof Error ? error.message : String(error),
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, errorItem])
throw error
} finally {
setIsExecuting(false)
// 滚动到底部
setTimeout(() => {
if (outputRef.current) {
outputRef.current.scrollTop = outputRef.current.scrollHeight
}
}, 0)
}
}
// 清空控制台
const clear = () => {
setOutputs([])
// 同时清空执行上下文
executionContextRef.current = {}
}
// 添加输出
const appendOutput = (content: string) => {
const outputItem: OutputItem = {
type: 'output',
content,
timestamp: Date.now(),
}
setOutputs((prev) => [...prev, outputItem])
}
// 暴露方法给父组件
useImperativeHandle(ref, () => ({
executeCode,
clear,
appendOutput,
}))
// 处理键盘事件
const handleKeyDown = (e: KeyboardEvent<HTMLTextAreaElement>) => {
if (e.key === 'Enter') {
if (e.shiftKey) {
// Shift+Enter 换行
return
} else {
// Enter 执行
e.preventDefault()
if (!isExecuting && input.trim()) {
executeCode(input)
setInput('')
setTimeout(() => inputRef.current?.focus(), 0)
}
}
}
}
function getPrompt(type: string) {
let prompt = ' '
if (type === 'input') {
prompt = '>'
} else if (type === 'output') {
prompt = '<'
}
return prompt
}
return (
<div className={styles.console} style={{ height }}>
{/* 历史记录和输入区域 */}
<div className={styles.historyArea} ref={outputRef}>
{outputs.map((item, index) => (
<div key={index} className={`${styles.historyItem} ${styles[item.type]}`}>
<span className={styles.prompt}>{getPrompt(item.type)}</span>
<pre className={styles.content}>
<HighlightSyntax code={item.content} />
</pre>
</div>
))}
{isExecuting && (
<div className={styles.historyItem}>
<span className={styles.prompt}>{'> '}</span>
<span className={styles.executing}>Executing...</span>
</div>
)}
</div>
{/* 当前输入行 */}
<div className={styles.inputArea}>
<span className={styles.prompt}>{'> '}</span>
<textarea
ref={inputRef}
className={styles.input}
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={placeholder}
disabled={isExecuting}
rows={1}
style={{
height: Math.min(Math.max(20, input.split('\n').length * 20), 120),
}}
/>
</div>
</div>
)
}
)
JSConsole.displayName = 'JSConsole'
export default JSConsole