chore(web-platform): release V0.6.1 工程质量优化
This commit is contained in:
@@ -1,29 +1,248 @@
|
||||
import './monacoSetup';
|
||||
import Editor from '@monaco-editor/react';
|
||||
import {useCallback,useEffect,useRef,useState,type PointerEvent as ReactPointerEvent} from 'react';
|
||||
import {Check,Code2,Copy,Download,Maximize2,Minimize2,Save,X} from 'lucide-react';
|
||||
import {downloadBytes} from '../../project/cachedFiles';
|
||||
import {Button,ConfirmDialog,IconButton} from '../../components/ui';
|
||||
import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type PointerEvent as ReactPointerEvent,
|
||||
} from 'react';
|
||||
import { Check, Code2, Copy, Download, Maximize2, Minimize2, Save, X } from 'lucide-react';
|
||||
import { downloadBytes } from '../../project/cachedFiles';
|
||||
import { Button, ConfirmDialog, IconButton } from '../../components/ui';
|
||||
|
||||
function basename(path:string):string{return path.split('/').at(-1)??path;}
|
||||
function contentSize(content:string):string{const bytes=new Blob([content]).size;return bytes<1024?`${bytes} B`:`${(bytes/1024).toFixed(1)} KB`;}
|
||||
function xmlProblem(code:string):string|undefined{const document=new DOMParser().parseFromString(code,'application/xml'),error=document.querySelector('parsererror');return error?.textContent?.split('\n')[0]||undefined;}
|
||||
|
||||
export function SourceEditorDialog({open,code:sourceCode,filePath,theme,onClose,onSave}:{open:boolean;code:string;filePath:string;theme:'light'|'dark';onClose:()=>void;onSave:(path:string,text:string)=>void|Promise<void>}){
|
||||
const [code,setCode]=useState(sourceCode),[savedCode,setSavedCode]=useState(sourceCode),[saving,setSaving]=useState(false),[copied,setCopied]=useState(false),[maximized,setMaximized]=useState(false),[discardOpen,setDiscardOpen]=useState(false),[position,setPosition]=useState(()=>({x:Math.max(24,(window.innerWidth-900)/2),y:Math.max(52,(window.innerHeight-650)/2)}));
|
||||
const drag=useRef<{x:number;y:number;left:number;top:number}|null>(null),dialog=useRef<HTMLElement>(null),previousFocus=useRef<HTMLElement|null>(null),dirty=code!==savedCode,problem=xmlProblem(code);
|
||||
const requestClose=useCallback(()=>{if(dirty)setDiscardOpen(true);else onClose();},[dirty,onClose]);
|
||||
const save=useCallback(async()=>{if(!dirty||problem)return;setSaving(true);try{await onSave(filePath,code);setSavedCode(code);}finally{setSaving(false);}},[code,dirty,filePath,onSave,problem]);
|
||||
useEffect(()=>{if(!open)return;previousFocus.current=document.activeElement instanceof HTMLElement?document.activeElement:null;requestAnimationFrame(()=>dialog.current?.focus());return()=>{if(previousFocus.current&&document.contains(previousFocus.current))previousFocus.current.focus();};},[open]);
|
||||
useEffect(()=>{const key=(event:KeyboardEvent)=>{if(discardOpen)return;if((event.ctrlKey||event.metaKey)&&event.key.toLowerCase()==='s'&&dirty&&!problem){event.preventDefault();void save();}else if(event.key==='Escape'){event.preventDefault();requestClose();}};window.addEventListener('keydown',key);return()=>window.removeEventListener('keydown',key);},[dirty,discardOpen,problem,requestClose,save]);
|
||||
const copy=async()=>{await navigator.clipboard.writeText(code);setCopied(true);window.setTimeout(()=>setCopied(false),1500);};
|
||||
const download=()=>downloadBytes(new TextEncoder().encode(code),basename(filePath),'application/xml');
|
||||
const pointerDown=(event:ReactPointerEvent)=>{if(maximized||event.button!==0||(event.target as HTMLElement).closest('button'))return;drag.current={x:event.clientX,y:event.clientY,left:position.x,top:position.y};event.currentTarget.setPointerCapture(event.pointerId);};
|
||||
const pointerMove=(event:ReactPointerEvent)=>{if(!drag.current)return;setPosition({x:Math.min(window.innerWidth-120,Math.max(-780,drag.current.left+event.clientX-drag.current.x)),y:Math.min(window.innerHeight-48,Math.max(0,drag.current.top+event.clientY-drag.current.y))});};
|
||||
if(!open)return null;
|
||||
return <><div className="fixed inset-0 z-[390] pointer-events-none" role="presentation"><section ref={dialog} tabIndex={-1} role="dialog" aria-modal="false" aria-label="转换后的 MJCF 编辑器" style={maximized?undefined:{left:position.x,top:position.y,width:900,height:650}} className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized?'inset-0 h-full w-full':'resize'}`}>
|
||||
<header className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3" onPointerDown={pointerDown} onPointerMove={pointerMove} onPointerUp={()=>{drag.current=null;}} onDoubleClick={()=>setMaximized(value=>!value)}><Code2 className="h-4 w-4 shrink-0 text-accent"/><div className="min-w-0 flex-1"><div className="truncate font-mono text-xs font-semibold text-text-primary">转换后的 MJCF</div><div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>{filePath}</div></div><span className="text-[10px] text-text-tertiary">{contentSize(code)}</span><span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">缓存文件 · 可编辑</span>{dirty&&<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">已修改</span>}<Button variant="primary" icon={<Save className="h-3 w-3"/>} disabled={!dirty||saving||Boolean(problem)} onClick={()=>void save()}>{saving?'重新载入中…':'保存并重新载入'}</Button><Button variant="ghost" icon={<Download className="h-3.5 w-3.5"/>} onClick={download}>下载</Button><Button variant="ghost" icon={copied?<Check className="h-3.5 w-3.5"/>:<Copy className="h-3.5 w-3.5"/>} onClick={()=>void copy()}>{copied?'已复制':'复制'}</Button><IconButton tooltip={maximized?'还原':'最大化'} aria-label={maximized?'还原':'最大化'} onClick={()=>setMaximized(value=>!value)}>{maximized?<Minimize2 className="h-4 w-4"/>:<Maximize2 className="h-4 w-4"/>}</IconButton><IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}><X className="h-4 w-4"/></IconButton></header>
|
||||
<div className="min-h-0 flex-1 bg-input"><Editor height="100%" language="xml" theme={theme==='light'?'light':'vs-dark'} value={code} onChange={value=>setCode(value??'')} options={{automaticLayout:true,minimap:{enabled:false},fontFamily:"'JetBrains Mono','Fira Code',ui-monospace,monospace",fontSize:13,fontLigatures:true,scrollBeyondLastLine:false,wordWrap:'off',stickyScroll:{enabled:false},tabSize:2,formatOnPaste:true,formatOnType:true,lineNumbersMinChars:4,padding:{top:12,bottom:14},renderLineHighlight:'all'}}/></div>
|
||||
<footer className="flex h-7 shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-[10px]"><div className={problem?'truncate text-warning':'text-success'}>{problem?`XML 错误:${problem}`:'✓ XML 结构正常'}</div><div className="flex items-center gap-2 font-mono text-text-tertiary"><span>Ctrl+S 保存并重新载入</span><span>•</span><span>MJCF / XML</span></div></footer>
|
||||
</section></div><ConfirmDialog open={discardOpen} title="放弃未保存的修改?" confirmLabel="放弃修改" cancelLabel="继续编辑" danger onConfirm={onClose} onClose={()=>setDiscardOpen(false)}><p className="text-sm text-text-secondary">当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。</p></ConfirmDialog></>;
|
||||
function basename(path: string): string {
|
||||
return path.split('/').at(-1) ?? path;
|
||||
}
|
||||
function contentSize(content: string): string {
|
||||
const bytes = new Blob([content]).size;
|
||||
return bytes < 1024 ? `${bytes} B` : `${(bytes / 1024).toFixed(1)} KB`;
|
||||
}
|
||||
function xmlProblem(code: string): string | undefined {
|
||||
const document = new DOMParser().parseFromString(code, 'application/xml'),
|
||||
error = document.querySelector('parsererror');
|
||||
return error?.textContent?.split('\n')[0] || undefined;
|
||||
}
|
||||
|
||||
export function SourceEditorDialog({
|
||||
open,
|
||||
code: sourceCode,
|
||||
filePath,
|
||||
theme,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
code: string;
|
||||
filePath: string;
|
||||
theme: 'light' | 'dark';
|
||||
onClose: () => void;
|
||||
onSave: (path: string, text: string) => void | Promise<void>;
|
||||
}) {
|
||||
const [code, setCode] = useState(sourceCode),
|
||||
[savedCode, setSavedCode] = useState(sourceCode),
|
||||
[saving, setSaving] = useState(false),
|
||||
[copied, setCopied] = useState(false),
|
||||
[maximized, setMaximized] = useState(false),
|
||||
[discardOpen, setDiscardOpen] = useState(false),
|
||||
[position, setPosition] = useState(() => ({
|
||||
x: Math.max(24, (window.innerWidth - 900) / 2),
|
||||
y: Math.max(52, (window.innerHeight - 650) / 2),
|
||||
}));
|
||||
const drag = useRef<{ x: number; y: number; left: number; top: number } | null>(null),
|
||||
dialog = useRef<HTMLElement>(null),
|
||||
previousFocus = useRef<HTMLElement | null>(null),
|
||||
dirty = code !== savedCode,
|
||||
problem = xmlProblem(code);
|
||||
const requestClose = useCallback(() => {
|
||||
if (dirty) setDiscardOpen(true);
|
||||
else onClose();
|
||||
}, [dirty, onClose]);
|
||||
const save = useCallback(async () => {
|
||||
if (!dirty || problem) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
await onSave(filePath, code);
|
||||
setSavedCode(code);
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [code, dirty, filePath, onSave, problem]);
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
previousFocus.current =
|
||||
document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
requestAnimationFrame(() => dialog.current?.focus());
|
||||
return () => {
|
||||
if (previousFocus.current && document.contains(previousFocus.current))
|
||||
previousFocus.current.focus();
|
||||
};
|
||||
}, [open]);
|
||||
useEffect(() => {
|
||||
const key = (event: KeyboardEvent) => {
|
||||
if (discardOpen) return;
|
||||
if (
|
||||
(event.ctrlKey || event.metaKey) &&
|
||||
event.key.toLowerCase() === 's' &&
|
||||
dirty &&
|
||||
!problem
|
||||
) {
|
||||
event.preventDefault();
|
||||
void save();
|
||||
} else if (event.key === 'Escape') {
|
||||
event.preventDefault();
|
||||
requestClose();
|
||||
}
|
||||
};
|
||||
window.addEventListener('keydown', key);
|
||||
return () => window.removeEventListener('keydown', key);
|
||||
}, [dirty, discardOpen, problem, requestClose, save]);
|
||||
const copy = async () => {
|
||||
await navigator.clipboard.writeText(code);
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
const download = () =>
|
||||
downloadBytes(new TextEncoder().encode(code), basename(filePath), 'application/xml');
|
||||
const pointerDown = (event: ReactPointerEvent) => {
|
||||
if (maximized || event.button !== 0 || (event.target as HTMLElement).closest('button')) return;
|
||||
drag.current = { x: event.clientX, y: event.clientY, left: position.x, top: position.y };
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
};
|
||||
const pointerMove = (event: ReactPointerEvent) => {
|
||||
if (!drag.current) return;
|
||||
setPosition({
|
||||
x: Math.min(
|
||||
window.innerWidth - 120,
|
||||
Math.max(-780, drag.current.left + event.clientX - drag.current.x),
|
||||
),
|
||||
y: Math.min(
|
||||
window.innerHeight - 48,
|
||||
Math.max(0, drag.current.top + event.clientY - drag.current.y),
|
||||
),
|
||||
});
|
||||
};
|
||||
if (!open) return null;
|
||||
return (
|
||||
<>
|
||||
<div className="fixed inset-0 z-[390] pointer-events-none" role="presentation">
|
||||
<section
|
||||
ref={dialog}
|
||||
tabIndex={-1}
|
||||
role="dialog"
|
||||
aria-modal="false"
|
||||
aria-label="转换后的 MJCF 编辑器"
|
||||
style={
|
||||
maximized ? undefined : { left: position.x, top: position.y, width: 900, height: 650 }
|
||||
}
|
||||
className={`source-editor-window pointer-events-auto fixed flex min-h-[360px] min-w-[520px] flex-col overflow-hidden border border-border-strong bg-panel shadow-2xl ${maximized ? 'inset-0 h-full w-full' : 'resize'}`}
|
||||
>
|
||||
<header
|
||||
className="flex h-11 shrink-0 cursor-move select-none items-center gap-3 border-b border-border bg-surface px-3"
|
||||
onPointerDown={pointerDown}
|
||||
onPointerMove={pointerMove}
|
||||
onPointerUp={() => {
|
||||
drag.current = null;
|
||||
}}
|
||||
onDoubleClick={() => setMaximized((value) => !value)}
|
||||
>
|
||||
<Code2 className="h-4 w-4 shrink-0 text-accent" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="truncate font-mono text-xs font-semibold text-text-primary">
|
||||
转换后的 MJCF
|
||||
</div>
|
||||
<div className="truncate font-mono text-[9px] text-text-tertiary" title={filePath}>
|
||||
{filePath}
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-[10px] text-text-tertiary">{contentSize(code)}</span>
|
||||
<span className="rounded bg-accent-soft px-1.5 py-0.5 text-[9px] font-semibold text-accent">
|
||||
缓存文件 · 可编辑
|
||||
</span>
|
||||
{dirty && (
|
||||
<span className="rounded bg-warning-soft px-1.5 py-0.5 text-[9px] font-semibold text-warning">
|
||||
已修改
|
||||
</span>
|
||||
)}
|
||||
<Button
|
||||
variant="primary"
|
||||
icon={<Save className="h-3 w-3" />}
|
||||
disabled={!dirty || saving || Boolean(problem)}
|
||||
onClick={() => void save()}
|
||||
>
|
||||
{saving ? '重新载入中…' : '保存并重新载入'}
|
||||
</Button>
|
||||
<Button variant="ghost" icon={<Download className="h-3.5 w-3.5" />} onClick={download}>
|
||||
下载
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
icon={copied ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
|
||||
onClick={() => void copy()}
|
||||
>
|
||||
{copied ? '已复制' : '复制'}
|
||||
</Button>
|
||||
<IconButton
|
||||
tooltip={maximized ? '还原' : '最大化'}
|
||||
aria-label={maximized ? '还原' : '最大化'}
|
||||
onClick={() => setMaximized((value) => !value)}
|
||||
>
|
||||
{maximized ? <Minimize2 className="h-4 w-4" /> : <Maximize2 className="h-4 w-4" />}
|
||||
</IconButton>
|
||||
<IconButton tooltip="关闭" aria-label="关闭源代码编辑器" onClick={requestClose}>
|
||||
<X className="h-4 w-4" />
|
||||
</IconButton>
|
||||
</header>
|
||||
<div className="min-h-0 flex-1 bg-input">
|
||||
<Editor
|
||||
height="100%"
|
||||
language="xml"
|
||||
theme={theme === 'light' ? 'light' : 'vs-dark'}
|
||||
value={code}
|
||||
onChange={(value) => setCode(value ?? '')}
|
||||
options={{
|
||||
automaticLayout: true,
|
||||
minimap: { enabled: false },
|
||||
fontFamily: "'JetBrains Mono','Fira Code',ui-monospace,monospace",
|
||||
fontSize: 13,
|
||||
fontLigatures: true,
|
||||
scrollBeyondLastLine: false,
|
||||
wordWrap: 'off',
|
||||
stickyScroll: { enabled: false },
|
||||
tabSize: 2,
|
||||
formatOnPaste: true,
|
||||
formatOnType: true,
|
||||
lineNumbersMinChars: 4,
|
||||
padding: { top: 12, bottom: 14 },
|
||||
renderLineHighlight: 'all',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<footer className="flex h-7 shrink-0 items-center justify-between gap-3 border-t border-border bg-surface px-3 text-[10px]">
|
||||
<div className={problem ? 'truncate text-warning' : 'text-success'}>
|
||||
{problem ? `XML 错误:${problem}` : '✓ XML 结构正常'}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 font-mono text-text-tertiary">
|
||||
<span>Ctrl+S 保存并重新载入</span>
|
||||
<span>•</span>
|
||||
<span>MJCF / XML</span>
|
||||
</div>
|
||||
</footer>
|
||||
</section>
|
||||
</div>
|
||||
<ConfirmDialog
|
||||
open={discardOpen}
|
||||
title="放弃未保存的修改?"
|
||||
confirmLabel="放弃修改"
|
||||
cancelLabel="继续编辑"
|
||||
danger
|
||||
onConfirm={onClose}
|
||||
onClose={() => setDiscardOpen(false)}
|
||||
>
|
||||
<p className="text-sm text-text-secondary">
|
||||
当前 MJCF 源码包含未保存的修改。关闭后,这些修改将无法恢复。
|
||||
</p>
|
||||
</ConfirmDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user