[next]主布局样式调整,更app化,去web管理后台风格化

This commit is contained in:
RememBerBer
2026-08-25 23:41:30 +08:00
parent b3ad3ab161
commit b244ca10d1
29 changed files with 675 additions and 155 deletions
+5 -2
View File
@@ -6,6 +6,7 @@ import { ToolWindow } from '@/features/workbench/ToolWindow'
import { ScreenColorPickerOverlay } from '@/features/color/ScreenColorPickerOverlay'
import { ScreenCaptureOverlay } from '@/features/image/ScreenCaptureOverlay'
import { ToastProvider, useToast } from '@/shared/feedback/ToastProvider'
import { DesktopDialogProvider } from '@/shared/feedback/DesktopDialogProvider'
import { I18nProvider, useI18n } from '@/shared/i18n/I18nProvider'
import { useSystemTheme } from '@/shared/theme/useSystemTheme'
@@ -28,8 +29,10 @@ function ThemedApp() {
return (
<ToastProvider>
{windowType !== 'tool' && !isScreenOverlay && <UpdateNotifications />}
{windowType === 'capture' ? <ScreenCaptureOverlay /> : windowType === 'color-picker' ? <ScreenColorPickerOverlay /> : windowType === 'settings' ? <SettingsWindow /> : windowType === 'tool' ? <ToolWindow requestedToolId={params.get('toolId') ?? ''} /> : <Workbench />}
<DesktopDialogProvider>
{windowType !== 'tool' && !isScreenOverlay && <UpdateNotifications />}
{windowType === 'capture' ? <ScreenCaptureOverlay /> : windowType === 'color-picker' ? <ScreenColorPickerOverlay /> : windowType === 'settings' ? <SettingsWindow /> : windowType === 'tool' ? <ToolWindow requestedToolId={params.get('toolId') ?? ''} /> : <Workbench />}
</DesktopDialogProvider>
</ToastProvider>
)
}
+3 -1
View File
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useState } from 'react'
import { Dialog } from '@/shared/components/Dialog'
import type { FuncHistoryRecord } from '@/shared/contracts/history'
import { useToast } from '@/shared/feedback/ToastProvider'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
type HistoryDialogProps = {
@@ -16,6 +17,7 @@ type HistoryDialogProps = {
export function HistoryDialog({ funcType, open, onClose, onApply, onApplyRecord }: HistoryDialogProps) {
const { language, t } = useI18n()
const toast = useToast()
const desktopDialog = useDesktopDialog()
const [records, setRecords] = useState<FuncHistoryRecord[]>([])
const [query, setQuery] = useState('')
@@ -41,7 +43,7 @@ export function HistoryDialog({ funcType, open, onClose, onApply, onApplyRecord
}
async function clearAll(): Promise<void> {
if (!window.confirm(t('history.confirmClear'))) return
if (!await desktopDialog.confirm(t('history.confirmClear'), { confirmLabel: t('history.clearAll'), danger: true })) return
await window.mootool.clearHistory(funcType)
await load()
}
+5 -3
View File
@@ -17,12 +17,14 @@ import {
import { useToolActivity } from '@/shared/components/ToolActivity'
import type { HostProfile, SystemHostsFile } from '@/shared/contracts/system'
import { useToolActions } from '@/shared/hooks/useToolActions'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
export function HostTool() {
const toolActive = useToolActivity()
const { t } = useI18n()
const actions = useToolActions('host')
const desktopDialog = useDesktopDialog()
const [profiles, setProfiles] = useState<HostProfile[]>([])
const [query, setQuery] = useState('')
const [includeContent, setIncludeContent] = useState(true)
@@ -114,7 +116,7 @@ export function HostTool() {
}
async function save(): Promise<void> {
const nextName = name.trim() || window.prompt(t('host.namePrompt'), t('host.untitled'))?.trim()
const nextName = name.trim() || (await desktopDialog.prompt(t('host.namePrompt'), { defaultValue: t('host.untitled'), confirmLabel: t('common.save') }))?.trim()
if (!nextName) return
try {
await persistProfile({ id: selected?.id, name: nextName, content }, true)
@@ -146,7 +148,7 @@ export function HostTool() {
}
async function remove(profile = selected): Promise<void> {
if (!profile || !window.confirm(t('host.confirmDelete'))) return
if (!profile || !await desktopDialog.confirm(t('host.confirmDelete'), { confirmLabel: t('common.action.delete'), danger: true })) return
try {
await window.mootool.deleteHostProfile(profile.id)
if (selected?.id === profile.id) {
@@ -164,7 +166,7 @@ export function HostTool() {
}
async function apply(): Promise<void> {
if (!content.trim() || !window.confirm(t('host.confirmApply'))) return
if (!content.trim() || !await desktopDialog.confirm(t('host.confirmApply'), { confirmLabel: t('common.action.apply') })) return
setApplying(true)
try {
const result = await window.mootool.writeSystemHosts(content, selected?.id)
+13 -6
View File
@@ -3,7 +3,7 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Dialog } from '@/shared/components/Dialog'
import { FindReplaceBar } from '@/shared/components/FindReplaceBar'
import { ResizableColumns } from '@/shared/components/ResizableColumns'
import { ToolPageHeader, ToolTabs } from '@/shared/components/ToolPage'
import { ToolTabs } from '@/shared/components/ToolPage'
import { TextCodeEditor, type TextCodeEditorHandle } from '@/shared/components/TextCodeEditor'
import { formatCodeEditorContent } from '@/shared/components/codeEditorFormatting'
import { resolveTextCodeEditorLanguage } from '@/shared/components/codeEditorLanguage'
@@ -15,6 +15,7 @@ import {
} from '@/shared/components/findReplace'
import { httpMethods, type HttpCookieEntry, type HttpRequestDraft, type HttpRequestHistory, type HttpResponseResult, type KeyValueEntry, type SavedHttpRequest } from '@/shared/contracts/network'
import { useToolActions } from '@/shared/hooks/useToolActions'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
import { useSettings } from '@/features/settings/SettingsProvider'
import { cookie, emptyHttpRequest, entry, parseCurlCommand, toCurlCommand } from './httpTools'
@@ -26,6 +27,7 @@ export function HttpTool() {
const { t } = useI18n()
const { settings, updateSettings } = useSettings()
const actions = useToolActions('http')
const desktopDialog = useDesktopDialog()
const [saved, setSaved] = useState<SavedHttpRequest[]>([])
const [search, setSearch] = useState('')
const [request, setRequest] = useState<HttpRequestDraft>(() => emptyHttpRequest(t('http.untitled')))
@@ -121,7 +123,7 @@ export function HttpTool() {
}
async function deleteRequest(): Promise<void> {
if (!request.id || !window.confirm(t('http.confirmDelete'))) return
if (!request.id || !await desktopDialog.confirm(t('http.confirmDelete'), { confirmLabel: t('common.action.delete'), danger: true })) return
try { await window.mootool.deleteHttpRequest(request.id); setRequest(emptyHttpRequest(t('http.untitled'))); setResponse(null); await loadSaved() } catch (error) { actions.reportError(error) }
}
@@ -180,13 +182,12 @@ export function HttpTool() {
: 'text'
return (
<section className="tool-page p5-tool http-tool-page">
<ToolPageHeader title={t('http.title')} actions={<button className="toolbar-button" type="button" onClick={() => setHistoryOpen(true)}><History size={14} />{t('common.action.history')}</button>} />
<section className="tool-page tool-page--workspace p5-tool http-tool-page" aria-label={t('http.title')}>
<ResizableColumns className="local-tool-shell http-workspace" columns={2} defaultSizes={[230, 770]} minPaneWidths={[180, 420]} storageKey="http-workspace">
<aside className="http-collection">
<header><div className="compact-search"><Search size={13} /><input value={search} aria-label={t('common.search')} placeholder={t('common.search')} onChange={(event) => setSearch(event.target.value)} /></div><button className="icon-button" type="button" aria-label={t('common.new')} onClick={() => { setRequest(emptyHttpRequest(t('http.untitled'))); setResponse(null) }}><Plus size={14} /></button></header>
<div className="http-saved-list">{saved.length === 0 ? <div className="history-empty">{t('http.savedEmpty')}</div> : saved.map((item) => <button className={item.id === request.id ? 'http-saved-item http-saved-item--active' : 'http-saved-item'} type="button" key={item.id} onClick={() => openSaved(item)}><strong>{item.name}</strong><span><em>{item.method}</em>{item.url || t('http.noUrl')}</span></button>)}</div>
<footer><button className="icon-button" type="button" aria-label={t('http.importCurl')} onClick={() => setCurlOpen(true)}><FileInput size={14} /></button><button className="icon-button" type="button" aria-label={t('http.copyCurl')} onClick={() => { void actions.copy(toCurlCommand(request)) }}><Code2 size={14} /></button><button className="icon-button" type="button" aria-label={t('common.save')} onClick={() => { setSaveName(request.name || t('http.untitled')); setSaveOpen(true) }}><Save size={14} /></button><button className="icon-button icon-button--danger" type="button" disabled={!request.id} aria-label={t('common.action.delete')} onClick={() => { void deleteRequest() }}><Trash2 size={14} /></button></footer>
<footer><button className="icon-button" type="button" aria-label={t('common.action.history')} onClick={() => setHistoryOpen(true)}><History size={14} /></button><button className="icon-button" type="button" aria-label={t('http.importCurl')} onClick={() => setCurlOpen(true)}><FileInput size={14} /></button><button className="icon-button" type="button" aria-label={t('http.copyCurl')} onClick={() => { void actions.copy(toCurlCommand(request)) }}><Code2 size={14} /></button><button className="icon-button" type="button" aria-label={t('common.save')} onClick={() => { setSaveName(request.name || t('http.untitled')); setSaveOpen(true) }}><Save size={14} /></button><button className="icon-button icon-button--danger" type="button" disabled={!request.id} aria-label={t('common.action.delete')} onClick={() => { void deleteRequest() }}><Trash2 size={14} /></button></footer>
</aside>
<main className="http-editor">
<div className="http-url-bar">
@@ -293,9 +294,15 @@ function CookieEditor({ entries, onChange }: { entries: HttpCookieEntry[]; onCha
function HttpHistoryDialog({ open, onClose, onApply }: { open: boolean; onClose: () => void; onApply: (item: HttpRequestHistory) => void }) {
const { t } = useI18n()
const desktopDialog = useDesktopDialog()
const [items, setItems] = useState<HttpRequestHistory[]>([])
const [query, setQuery] = useState('')
const load = useCallback(async () => setItems(await window.mootool.listHttpHistory(query)), [query])
useEffect(() => { if (open) void load() }, [load, open])
return <Dialog title={t('http.history')} open={open} width={820} onClose={onClose} footer={<><button className="dialog-button dialog-button--danger" type="button" disabled={!items.length} onClick={() => { if (window.confirm(t('history.confirmClear'))) void window.mootool.clearHttpHistory().then(load) }}><Trash2 size={14} />{t('history.clearAll')}</button><button className="dialog-button" type="button" onClick={onClose}>{t('common.close')}</button></>}><div className="history-search"><Search size={14} /><input value={query} placeholder={t('history.search')} onChange={(event) => setQuery(event.target.value)} /></div><div className="http-history-list">{items.length === 0 ? <div className="history-empty">{t('history.empty')}</div> : items.map((item) => <article key={item.id}><button type="button" onClick={() => { onApply(item); onClose() }}><strong><em>{item.method}</em>{item.title || item.url}</strong><span>{item.status} · {item.costTime} ms · {item.createTime}</span><p>{item.url}</p></button><button className="icon-button" type="button" aria-label={t('history.delete')} onClick={() => { void window.mootool.deleteHttpHistory(item.id).then(load) }}><Trash2 size={13} /></button></article>)}</div></Dialog>
async function clearAll(): Promise<void> {
if (!await desktopDialog.confirm(t('history.confirmClear'), { confirmLabel: t('history.clearAll'), danger: true })) return
await window.mootool.clearHttpHistory()
await load()
}
return <Dialog title={t('http.history')} open={open} width={820} onClose={onClose} footer={<><button className="dialog-button dialog-button--danger" type="button" disabled={!items.length} onClick={() => { void clearAll() }}><Trash2 size={14} />{t('history.clearAll')}</button><button className="dialog-button" type="button" onClick={onClose}>{t('common.close')}</button></>}><div className="history-search"><Search size={14} /><input value={query} placeholder={t('history.search')} onChange={(event) => setQuery(event.target.value)} /></div><div className="http-history-list">{items.length === 0 ? <div className="history-empty">{t('history.empty')}</div> : items.map((item) => <article key={item.id}><button type="button" onClick={() => { onApply(item); onClose() }}><strong><em>{item.method}</em>{item.title || item.url}</strong><span>{item.status} · {item.costTime} ms · {item.createTime}</span><p>{item.url}</p></button><button className="icon-button" type="button" aria-label={t('history.delete')} onClick={() => { void window.mootool.deleteHttpHistory(item.id).then(load) }}><Trash2 size={13} /></button></article>)}</div></Dialog>
}
+5 -3
View File
@@ -4,6 +4,7 @@ import { ToolPageHeader } from '@/shared/components/ToolPage'
import { ResizableColumns } from '@/shared/components/ResizableColumns'
import type { ImageAsset, ImageAssetSummary, ImageVectorizeOptions } from '@/shared/contracts/images'
import { useToolActions } from '@/shared/hooks/useToolActions'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
import { ImageBase64Dialog, ImageCompressDialog, ImageSvgDialog, ImageWatermarkDialog } from './ImageDialogs'
import { compressImage, ensureImageDataUrl, processedImageName, watermarkImage, type CompressImageOptions, type ImageOutputMode, type WatermarkImageOptions } from './imageTools'
@@ -11,6 +12,7 @@ import { compressImage, ensureImageDataUrl, processedImageName, watermarkImage,
export function ImageTool() {
const { t } = useI18n()
const actions = useToolActions('image')
const desktopDialog = useDesktopDialog()
const [assets, setAssets] = useState<ImageAssetSummary[]>([])
const [selectedNames, setSelectedNames] = useState<string[]>([])
const [current, setCurrent] = useState<ImageAsset | null>(null)
@@ -101,20 +103,20 @@ export function ImageTool() {
async function saveCurrent(): Promise<void> {
if (!current) return
const name = window.prompt(t('image.saveName'), current.name)
const name = await desktopDialog.prompt(t('image.saveName'), { defaultValue: current.name, confirmLabel: t('common.save') })
if (!name) return
try { const saved = await window.mootool.saveImageAsset({ name, dataUrl: current.dataUrl }); await loadAssets(saved.name); actions.toast.success(t('common.saved')) } catch (error) { actions.reportError(error) }
}
async function renameCurrent(): Promise<void> {
if (!current) return
const nextName = window.prompt(t('image.renamePrompt'), current.name)
const nextName = await desktopDialog.prompt(t('image.renamePrompt'), { defaultValue: current.name, confirmLabel: t('common.rename') })
if (!nextName || nextName === current.name) return
try { const renamed = await window.mootool.renameImageAsset({ name: current.name, nextName }); await loadAssets(renamed.name) } catch (error) { actions.reportError(error) }
}
async function deleteSelected(): Promise<void> {
if (!processingNames.length || !window.confirm(t('image.confirmDelete', { count: String(processingNames.length) }))) return
if (!processingNames.length || !await desktopDialog.confirm(t('image.confirmDelete', { count: String(processingNames.length) }), { confirmLabel: t('common.action.delete'), danger: true })) return
try { await window.mootool.deleteImageAssets(processingNames); setCurrent(null); setSelectedNames([]); await loadAssets(); actions.toast.success(t('favorite.deleted')) } catch (error) { actions.reportError(error) }
}
@@ -39,7 +39,7 @@ export function ScreenCaptureOverlay() {
void cancel()
} else if (event.key === 'Enter' && selection) {
event.preventDefault()
void confirm()
void acceptCapture()
}
}
window.addEventListener('keydown', keyDown)
@@ -83,7 +83,7 @@ export function ScreenCaptureOverlay() {
if (event.currentTarget.hasPointerCapture(event.pointerId)) event.currentTarget.releasePointerCapture(event.pointerId)
}
async function confirm(): Promise<void> {
async function acceptCapture(): Promise<void> {
if (!selection || confirmingRef.current) return
confirmingRef.current = true
await window.mootool.confirmScreenCapture(selection)
@@ -123,7 +123,7 @@ export function ScreenCaptureOverlay() {
))}
<div className="screen-capture-overlay__commands" data-capture-command>
<button type="button" aria-label={t('common.cancel')} onClick={() => { void cancel() }}><X size={17} /></button>
<button className="screen-capture-overlay__confirm" type="button" aria-label={t('image.captureConfirm')} onClick={() => { void confirm() }}><Check size={17} /></button>
<button className="screen-capture-overlay__confirm" type="button" aria-label={t('image.captureConfirm')} onClick={() => { void acceptCapture() }}><Check size={17} /></button>
</div>
</div>
)}
+5 -8
View File
@@ -309,14 +309,7 @@ export function JsonTool() {
}
return (
<section className="tool-page json-tool">
<div className="tool-page__header">
<h1>{t('json.title')}</h1>
<div className={status.kind === 'valid' ? 'status-pill status-pill--valid' : status.kind === 'error' ? 'status-pill status-pill--error' : 'status-pill'}>
{status.kind === 'valid' ? <CheckCircle2 size={14} /> : status.kind === 'error' ? <XCircle size={14} /> : <FileJson size={14} />}
{status.message}
</div>
</div>
<section className="tool-page tool-page--workspace json-tool" aria-label={t('json.title')}>
<ResizableColumns
className={state.inspectorOpen ? 'json-layout' : 'json-layout json-layout--editor-only'}
@@ -378,6 +371,10 @@ export function JsonTool() {
}}
onViewStateChange={(viewState) => { jsonEditorViewState = viewState }}
/>
<div className={status.kind === 'valid' ? 'json-statusbar json-statusbar--valid' : status.kind === 'error' ? 'json-statusbar json-statusbar--error' : 'json-statusbar'}>
{status.kind === 'valid' ? <CheckCircle2 size={12} /> : status.kind === 'error' ? <XCircle size={12} /> : <FileJson size={12} />}
<span>{status.message}</span>
</div>
</div>
{state.inspectorOpen && (
+3 -1
View File
@@ -27,6 +27,7 @@ import { useToolActivity } from '@/shared/components/ToolActivity'
import type { JsonVaultNode } from '@/shared/contracts/jsonVault'
import type { VaultGitAction } from '@/shared/contracts/vaultGit'
import { useToast } from '@/shared/feedback/ToastProvider'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
import {
collectDirectoryPaths,
@@ -83,6 +84,7 @@ let jsonVaultNeedsExpandPreference = true
export function JsonVaultPanel({ content, onOpen }: JsonVaultPanelProps) {
const toolActive = useToolActivity()
const { t } = useI18n()
const desktopDialog = useDesktopDialog()
const { settings, updateSettings } = useSettings()
const toast = useToast()
const [nodes, setNodes] = useState<JsonVaultNode[]>(jsonVaultSessionState.nodes)
@@ -398,7 +400,7 @@ export function JsonVaultPanel({ content, onOpen }: JsonVaultPanelProps) {
}
async function deleteEntry(entry = selectedEntry): Promise<void> {
if (!entry || !window.confirm(t('json.vault.confirmDelete', { name: entry.path }))) return
if (!entry || !await desktopDialog.confirm(t('json.vault.confirmDelete', { name: entry.path }), { confirmLabel: t('common.action.delete'), danger: true })) return
try {
await window.mootool.deleteJsonVaultFile(entry.path)
if (entry.path === selectedPath) {
+4 -4
View File
@@ -17,6 +17,7 @@ import type {
VaultGitStatus
} from '@/shared/contracts/vaultGit'
import { useToast } from '@/shared/feedback/ToastProvider'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
import { compareText, type DiffSegment } from '../diff/diffTools'
@@ -50,6 +51,7 @@ export function VaultGitDialog({ open, onClose, onVaultChange, beforeWorkingTree
const { t } = useI18n()
const { settings, updateSettings } = useSettings()
const toast = useToast()
const desktopDialog = useDesktopDialog()
const [state, update] = useReducer(updateState, {
status: null,
history: [],
@@ -161,7 +163,7 @@ export function VaultGitDialog({ open, onClose, onVaultChange, beforeWorkingTree
{status?.repository && <button type="button" disabled={state.busy || !status.remote || status.merging} onClick={() => { void runAction('pull') }}>{t('json.git.pull')}</button>}
{status?.repository && <button type="button" disabled={state.busy || !status.remote || status.merging} onClick={() => { void runAction('push') }}><CloudUpload size={13} />{t('json.git.push')}</button>}
{status?.repository && (status.merging || status.conflicts > 0) && <button className="git-danger-button" type="button" disabled={state.busy} onClick={() => {
if (window.confirm(t('json.git.confirmAbort'))) void runAction('abort-merge')
void desktopDialog.confirm(t('json.git.confirmAbort'), { confirmLabel: t('json.git.abortMerge'), danger: true }).then((confirmed) => { if (confirmed) return runAction('abort-merge') })
}}><GitMerge size={13} />{t('json.git.abortMerge')}</button>}
{status?.repository && status.merging && status.conflicts === 0 && <button type="button" disabled={state.busy} onClick={() => { void runAction('continue-operation') }}><GitMerge size={13} />{t('json.git.continueOperation')}</button>}
</div>
@@ -199,9 +201,7 @@ export function VaultGitDialog({ open, onClose, onVaultChange, beforeWorkingTree
{status?.repository && state.tab === 'changes' && selectedChange && (
<div className="git-change-actions">
<button type="button" disabled={state.busy} onClick={() => {
if (window.confirm(t('json.git.confirmDiscard', { path: selectedChange.path }))) {
void runAction('discard', { path: selectedChange.path })
}
void desktopDialog.confirm(t('json.git.confirmDiscard', { path: selectedChange.path }), { confirmLabel: t('json.git.discard'), danger: true }).then((confirmed) => { if (confirmed) return runAction('discard', { path: selectedChange.path }) })
}}><Undo2 size={13} />{t('json.git.discard')}</button>
{selectedChange.conflict && <>
<button type="button" disabled={state.busy} onClick={() => { void runAction('resolve-conflict', { path: selectedChange.path, strategy: 'ours' }) }}><ShieldCheck size={13} />{t('json.git.useOurs')}</button>
+3 -1
View File
@@ -4,6 +4,7 @@ import { Dialog } from '@/shared/components/Dialog'
import { ToolPageHeader, ToolTabs } from '@/shared/components/ToolPage'
import type { PdfFileInfo, PdfSplitRule } from '@/shared/contracts/pdf'
import { useToolActions } from '@/shared/hooks/useToolActions'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
type PdfTab = 'split' | 'merge'
@@ -14,6 +15,7 @@ type MergeRow = PdfFileInfo & { selected: boolean; pages: string; status: TaskSt
export function PdfTool() {
const { t } = useI18n()
const actions = useToolActions('pdf')
const desktopDialog = useDesktopDialog()
const [tab, setTab] = useState<PdfTab>('split')
const [splitRows, setSplitRows] = useState<SplitRow[]>([])
const [mergeRows, setMergeRows] = useState<MergeRow[]>([])
@@ -38,7 +40,7 @@ export function PdfTool() {
async function splitFiles(): Promise<void> {
const selected = splitRows.filter((row) => row.selected)
if (selected.length === 0) { actions.toast.error(t('pdf.selectTask')); return }
if (!window.confirm(t('pdf.confirmSplit'))) return
if (!await desktopDialog.confirm(t('pdf.confirmSplit'))) return
setBusy(true)
setSplitRows((rows) => rows.map((row) => row.selected ? { ...row, status: 'running' } : row))
try {
+2 -3
View File
@@ -2,7 +2,7 @@ import { ClipboardPaste, Copy, Download, FolderOpen, History, QrCode, ScanLine,
import { useCallback, useEffect, useState } from 'react'
import { useSettings } from '@/features/settings/SettingsProvider'
import { ResizableColumns } from '@/shared/components/ResizableColumns'
import { ToolPageHeader, ToolTabs } from '@/shared/components/ToolPage'
import { ToolTabs } from '@/shared/components/ToolPage'
import { TextCodeEditor } from '@/shared/components/TextCodeEditor'
import type { FuncHistoryRecord } from '@/shared/contracts/history'
import { useToolActions } from '@/shared/hooks/useToolActions'
@@ -100,8 +100,7 @@ export function QrCodeTool() {
}
return (
<section className="tool-page p4-tool">
<ToolPageHeader title={t('qrcode.title')} />
<section className="tool-page tool-page--workspace p4-tool qrcode-tool-page" aria-label={t('qrcode.title')}>
<div className="local-tool-shell qrcode-workspace">
<ToolTabs tabs={[{ id: 'generate', label: t('qrcode.tab.generate') }, { id: 'recognize', label: t('qrcode.tab.recognize') }, { id: 'history', label: t('qrcode.tab.history') }]} active={tab} onChange={setTab} />
{tab === 'generate' && <ResizableColumns className="qrcode-generate-layout" columns={2} defaultSizes={[1, 1]} minPaneWidths={[300, 280]} storageKey="qrcode-generate"><section className="qrcode-input-panel"><div className="qrcode-content-editor"><span>{t('qrcode.content')}</span><TextCodeEditor ariaLabel={t('qrcode.content')} value={content} onChange={setContent} /></div><div className="qrcode-options"><label><span>{t('qrcode.size')}</span><div><input type="number" min={120} max={2000} value={size} onChange={(event) => setSize(Number(event.target.value))} /><em>px</em></div></label><label><span>{t('qrcode.correction')}</span><select value={correction} onChange={(event) => setCorrection(event.target.value as QrErrorCorrection)}>{(['L', 'M', 'Q', 'H'] as const).map((level) => <option value={level} key={level}>{t(`qrcode.level.${level}` as 'qrcode.level.L')}</option>)}</select></label><label><span>{t('qrcode.logo')}</span><button className="dialog-button" type="button" onClick={() => { void chooseLogo() }}><FolderOpen size={14} />{logoName || t('qrcode.chooseLogo')}</button></label></div><button className="primary-command qrcode-generate-button" type="button" disabled={busy || !content.trim()} onClick={() => { void generate() }}><QrCode size={15} />{busy ? t('common.processing') : t('qrcode.generate')}</button></section><section className="qrcode-preview-panel">{qrDataUrl ? <img src={qrDataUrl} alt={t('qrcode.preview')} /> : <div className="image-placeholder"><QrCode size={42} /><span>{t('qrcode.preview')}</span></div>}<div className="qrcode-preview-actions"><button className="dialog-button" type="button" disabled={!qrDataUrl} onClick={() => { void saveQr() }}><Download size={14} />{t('common.save')}</button><button className="dialog-button" type="button" disabled={!qrDataUrl} onClick={() => { void window.mootool.writeClipboardImage(qrDataUrl); actions.toast.success(t('json.notice.copied')) }}><Copy size={14} />{t('common.action.copy')}</button></div></section></ResizableColumns>}
@@ -927,8 +927,8 @@ export function QuickNoteTool() {
: state.quickReplaceOpen ? [320, 170] : [320]
return (
<section className="tool-page quick-note-tool">
<div className="tool-page__header quick-note-page-header">
<h1>{t('quickNote.title')}</h1>
<div className="tool-page__header tool-page__header--actions quick-note-page-header">
<h1 className="visually-hidden">{t('quickNote.title')}</h1>
<div className="quick-note-view-switch segmented" role="tablist">
{(['editor', 'split', 'preview'] as const).map((mode) => (
<button className={state.viewMode === mode ? 'segmented__item segmented__item--active' : 'segmented__item'} type="button" role="tab" aria-selected={state.viewMode === mode} key={mode} onClick={() => update({ viewMode: mode })}>
+2 -2
View File
@@ -210,8 +210,8 @@ export function RuntimeTool() {
return (
<section className="tool-page runtime-tool">
<div className="tool-page__header">
<h1>{t('runtime.title')}</h1>
<div className="tool-page__header tool-page__header--actions">
<h1 className="visually-hidden">{t('runtime.title')}</h1>
<div className={availableCount > 0 ? 'status-pill status-pill--valid' : 'status-pill status-pill--error'}>
{state.detecting ? <LoaderCircle className="spin" size={14} /> : availableCount > 0 ? <CheckCircle2 size={14} /> : <CircleAlert size={14} />}
{statusText}
+2 -2
View File
@@ -98,8 +98,8 @@ export function TimeConvertTool() {
const currentLocalTime = formatLocalTime(state.now, state.zone)
return (
<section className="tool-page time-tool">
<div className="tool-page__header">
<h1>{t('time.title')}</h1>
<div className="tool-page__header tool-page__header--actions">
<h1 className="visually-hidden">{t('time.title')}</h1>
<div className="tool-header-actions">
<button className="toolbar-button" type="button" onClick={() => update({ historyOpen: true })}><History size={14} />{t('time.history')}</button>
<button className="toolbar-button" type="button" onClick={() => update({ clockOpen: true })}><Expand size={14} />{t('time.clock')}</button>
@@ -6,6 +6,7 @@ import { useSettings } from '@/features/settings/SettingsProvider'
import { ResizableColumns } from '@/shared/components/ResizableColumns'
import { TextCodeEditor } from '@/shared/components/TextCodeEditor'
import { useToolActions } from '@/shared/hooks/useToolActions'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
import type { MessageKey } from '@/shared/i18n/messages'
@@ -173,23 +174,30 @@ function LanguageSelect({ value, includeAuto = false, onChange }: { value: strin
function WordBook({ onApply, onRetranslate }: { onApply: (word: TranslationWord) => void; onRetranslate: (word: TranslationWord) => Promise<TranslationWord> }) {
const { t } = useI18n()
const actions = useToolActions('translation')
const desktopDialog = useDesktopDialog()
const [query, setQuery] = useState('')
const [items, setItems] = useState<TranslationWord[]>([])
const [selected, setSelected] = useState<TranslationWord | null>(null)
const load = useCallback(async () => { const next = await window.mootool.listTranslationWords(query); setItems(next); setSelected((current) => current ? next.find((item) => item.id === current.id) ?? null : next[0] ?? null) }, [query])
useEffect(() => { const timer = window.setTimeout(() => { void load() }, 100); return () => clearTimeout(timer) }, [load])
async function save(): Promise<void> { if (!selected) return; try { setSelected(await window.mootool.saveTranslationWord(selected)); await load(); actions.toast.success(t('common.saved')) } catch (error) { actions.reportError(error) } }
async function remove(): Promise<void> { if (!selected || !window.confirm(t('translation.confirmDeleteWord'))) return; await window.mootool.deleteTranslationWord(selected.id); setSelected(null); await load() }
async function remove(): Promise<void> { if (!selected || !await desktopDialog.confirm(t('translation.confirmDeleteWord'), { confirmLabel: t('common.action.delete'), danger: true })) return; await window.mootool.deleteTranslationWord(selected.id); setSelected(null); await load() }
return <ResizableColumns className="translation-record-layout" columns={2} defaultSizes={[220, 780]} minPaneWidths={[170, 360]} storageKey="translation-words"><aside><div className="compact-search"><Search size={13} /><input aria-label={t('translation.searchWords')} value={query} placeholder={t('translation.searchWords')} onChange={(event) => setQuery(event.target.value)} /></div><div className="translation-record-list">{items.map((item) => <button className={item.id === selected?.id ? 'translation-record translation-record--active' : 'translation-record'} type="button" key={item.id} onClick={() => setSelected(item)}><strong>{item.sourceText}</strong><span>{item.targetText}</span></button>)}</div><footer><button className="icon-button" type="button" aria-label={t('common.add')} onClick={() => setSelected({ id: 0, sourceText: '', targetText: '', sourceLang: 'auto', targetLang: 'zh-CN', remark: '', createTime: '', modifiedTime: '' })}><Plus size={14} /></button><button className="icon-button icon-button--danger" type="button" disabled={!selected?.id} aria-label={t('common.action.delete')} onClick={() => { void remove() }}><Trash2 size={14} /></button></footer></aside><main>{selected ? <><header><span>{languageLabel(selected.sourceLang, t)} {languageLabel(selected.targetLang, t)}</span><div><button className="toolbar-button" type="button" onClick={() => onApply(selected)}><Languages size={14} />{t('translation.apply')}</button><button className="toolbar-button" type="button" onClick={() => { void onRetranslate(selected).then((word) => { setSelected(word); void load() }) }}><History size={14} />{t('translation.retranslate')}</button></div></header><TextCodeEditor className="translation-record-editor" ariaLabel={t('translation.sourcePlaceholder')} value={selected.sourceText} placeholder={t('translation.sourcePlaceholder')} onChange={(sourceText) => setSelected({ ...selected, sourceText })} /><TextCodeEditor className="translation-record-editor" ariaLabel={t('translation.targetPlaceholder')} value={selected.targetText} placeholder={t('translation.targetPlaceholder')} onChange={(targetText) => setSelected({ ...selected, targetText })} /><input aria-label={t('translation.remark')} value={selected.remark} placeholder={t('translation.remark')} onChange={(event) => setSelected({ ...selected, remark: event.target.value })} /><button className="dialog-button" type="button" onClick={() => { void save() }}><Save size={14} />{t('common.save')}</button></> : <div className="history-empty">{t('translation.wordEmpty')}</div>}</main></ResizableColumns>
}
function TranslationHistoryPanel({ onApply }: { onApply: (item: TranslationHistory) => void }) {
const { t } = useI18n()
const desktopDialog = useDesktopDialog()
const [query, setQuery] = useState('')
const [items, setItems] = useState<TranslationHistory[]>([])
const load = useCallback(async () => setItems(await window.mootool.listTranslationHistory(query)), [query])
useEffect(() => { const timer = window.setTimeout(() => { void load() }, 100); return () => clearTimeout(timer) }, [load])
return <div className="translation-history"><div className="compact-search"><Search size={13} /><input aria-label={t('translation.searchHistory')} value={query} placeholder={t('translation.searchHistory')} onChange={(event) => setQuery(event.target.value)} /></div><div className="translation-history-list">{items.length === 0 ? <div className="history-empty">{t('history.empty')}</div> : items.map((item) => <article key={item.id}><button type="button" onClick={() => onApply(item)}><header><strong>{languageLabel(item.sourceLang, t)} {languageLabel(item.targetLang, t)}</strong><span>{item.translatorType} · {item.createTime}</span></header><p>{item.sourceText}</p><p>{item.targetText}</p></button><button className="icon-button" type="button" aria-label={t('history.delete')} onClick={() => { void window.mootool.deleteTranslationHistory(item.id).then(load) }}><Trash2 size={13} /></button></article>)}</div><footer><button className="dialog-button dialog-button--danger" type="button" disabled={!items.length} onClick={() => { if (window.confirm(t('history.confirmClear'))) void window.mootool.clearTranslationHistory().then(load) }}><Trash2 size={14} />{t('history.clearAll')}</button></footer></div>
async function clearAll(): Promise<void> {
if (!await desktopDialog.confirm(t('history.confirmClear'), { confirmLabel: t('history.clearAll'), danger: true })) return
await window.mootool.clearTranslationHistory()
await load()
}
return <div className="translation-history"><div className="compact-search"><Search size={13} /><input aria-label={t('translation.searchHistory')} value={query} placeholder={t('translation.searchHistory')} onChange={(event) => setQuery(event.target.value)} /></div><div className="translation-history-list">{items.length === 0 ? <div className="history-empty">{t('history.empty')}</div> : items.map((item) => <article key={item.id}><button type="button" onClick={() => onApply(item)}><header><strong>{languageLabel(item.sourceLang, t)} {languageLabel(item.targetLang, t)}</strong><span>{item.translatorType} · {item.createTime}</span></header><p>{item.sourceText}</p><p>{item.targetText}</p></button><button className="icon-button" type="button" aria-label={t('history.delete')} onClick={() => { void window.mootool.deleteTranslationHistory(item.id).then(load) }}><Trash2 size={13} /></button></article>)}</div><footer><button className="dialog-button dialog-button--danger" type="button" disabled={!items.length} onClick={() => { void clearAll() }}><Trash2 size={14} />{t('history.clearAll')}</button></footer></div>
}
async function translateWord(word: TranslationWord, settings: ReturnType<typeof useSettings>['settings'], reportError: (error: unknown) => void): Promise<TranslationWord> {
@@ -4,6 +4,7 @@ import { Dialog } from '@/shared/components/Dialog'
import { ToolPageHeader, ToolTabs } from '@/shared/components/ToolPage'
import type { EnvironmentEntry, EnvironmentScope, EnvironmentSnapshot } from '@/shared/contracts/system'
import { useToolActions } from '@/shared/hooks/useToolActions'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
type VariableTab = 'environment' | 'runtime'
@@ -14,6 +15,7 @@ const emptySnapshot: EnvironmentSnapshot = { environment: [], runtime: [], user:
export function VariablesTool() {
const { t } = useI18n()
const actions = useToolActions('variables')
const desktopDialog = useDesktopDialog()
const [tab, setTab] = useState<VariableTab>('environment')
const [scope, setScope] = useState<DisplayScope>('process')
const [targetScope, setTargetScope] = useState<EnvironmentScope>('user')
@@ -78,7 +80,7 @@ export function VariablesTool() {
}
async function deleteVariable(entry: EnvironmentEntry): Promise<void> {
if (scope === 'process' || !window.confirm(t('variables.confirmDelete', { key: entry.key }))) return
if (scope === 'process' || !await desktopDialog.confirm(t('variables.confirmDelete', { key: entry.key }), { confirmLabel: t('common.action.delete'), danger: true })) return
setSaving(true)
try {
await window.mootool.deleteEnvironmentVariable({ scope, key: entry.key })
@@ -5,6 +5,7 @@ import { useSettings } from '@/features/settings/SettingsProvider'
import { Dialog } from '@/shared/components/Dialog'
import type { CustomToolGroup } from '@/shared/contracts/settings'
import { useToast } from '@/shared/feedback/ToastProvider'
import { useDesktopDialog } from '@/shared/feedback/DesktopDialogProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
type CustomGroupManagerProps = {
@@ -16,6 +17,7 @@ export function CustomGroupManager({ open, onClose }: CustomGroupManagerProps) {
const { settings, updateSettings } = useSettings()
const { t } = useI18n()
const toast = useToast()
const desktopDialog = useDesktopDialog()
const [groups, setGroups] = useState<CustomToolGroup[]>([])
const [selectedId, setSelectedId] = useState<string>()
const [saving, setSaving] = useState(false)
@@ -54,8 +56,8 @@ export function CustomGroupManager({ open, onClose }: CustomGroupManagerProps) {
updateSelected({ toolIds })
}
function deleteSelected(): void {
if (!selectedGroup || !window.confirm(t('app.group.manage.deleteConfirm', { name: selectedGroup.name }))) return
async function deleteSelected(): Promise<void> {
if (!selectedGroup || !await desktopDialog.confirm(t('app.group.manage.deleteConfirm', { name: selectedGroup.name }), { confirmLabel: t('app.group.manage.delete'), danger: true })) return
const selectedIndex = groups.findIndex((group) => group.id === selectedGroup.id)
const nextGroups = groups.filter((group) => group.id !== selectedGroup.id)
setGroups(nextGroups)
@@ -87,7 +89,7 @@ export function CustomGroupManager({ open, onClose }: CustomGroupManagerProps) {
onClose={onClose}
footer={(
<>
<button className="dialog-button dialog-button--danger" type="button" disabled={!selectedGroup || saving} onClick={deleteSelected}>
<button className="dialog-button dialog-button--danger" type="button" disabled={!selectedGroup || saving} onClick={() => { void deleteSelected() }}>
<Trash2 size={14} />{t('app.group.manage.delete')}
</button>
<button className="dialog-button" type="button" disabled={saving} onClick={onClose}>{t('common.cancel')}</button>
+7 -3
View File
@@ -6,6 +6,7 @@ import { ToolActivityProvider } from '@/shared/components/ToolActivity'
import { Tooltip } from '@/shared/components/Tooltip'
import { isDetachableToolId, type ToolWindowStatus } from '@/shared/contracts/app'
import { useI18n } from '@/shared/i18n/I18nProvider'
import { isImmersiveToolId } from './immersiveTools'
export function ToolWindow({ requestedToolId }: { requestedToolId: string }) {
const { t } = useI18n()
@@ -40,16 +41,18 @@ export function ToolWindow({ requestedToolId }: { requestedToolId: string }) {
}
const detached = status?.detached ?? false
const immersive = isImmersiveToolId(tool.id)
const shellClassName = [
'tool-view-shell',
detached ? 'tool-view-shell--detached' : '',
immersive ? 'tool-view-shell--immersive' : '',
window.mootool.platform === 'darwin' ? 'tool-view-shell--macos' : '',
windowControlsVisible ? 'tool-view-shell--window-controls-visible' : ''
].filter(Boolean).join(' ')
return (
<main className={shellClassName}>
<div className="window-drag window-drag-region" aria-hidden="true" />
{detached && (
{detached && !immersive && (
<div
className={windowControlsVisible ? 'tool-window-brand-zone tool-window-brand-zone--controls-visible' : 'tool-window-brand-zone'}
data-window-controls-visible={windowControlsVisible}
@@ -58,12 +61,13 @@ export function ToolWindow({ requestedToolId }: { requestedToolId: string }) {
</div>
)}
<div className="tool-window-toggle-slot">
<Tooltip content={detached ? t('toolWindow.dock') : t('toolWindow.detach')} side="bottom">
<Tooltip content={detached ? t('toolWindow.dock') : t('toolWindow.detach')} side={immersive ? 'left' : 'bottom'}>
<button
className="toolbar-button toolbar-button--icon tool-window-toggle"
type="button"
aria-label={detached ? t('toolWindow.dock') : t('toolWindow.detach')}
onClick={() => {
onClick={(event) => {
event.currentTarget.blur()
if (detached) {
void window.mootool.dockToolWindow(toolId)
}
@@ -13,6 +13,7 @@ import { LegacyMigrationHintDialog } from '@/features/settings/LegacyMigrationHi
import { useSettings } from '@/features/settings/SettingsProvider'
import { CustomGroupManager } from './CustomGroupManager'
import { UpdateReadyAction } from './UpdateReadyAction'
import { isImmersiveToolId } from './immersiveTools'
export function Workbench() {
const { language, languageLabels, languages, setLanguage, t } = useI18n()
@@ -112,6 +113,7 @@ export function Workbench() {
settings.layout.hideNavigationTitles ? 'app-shell--hide-nav-titles' : '',
settings.appearance.unifiedBackground ? 'app-shell--unified-background' : '',
dockedToolViewActive ? 'app-shell--tool-view-docked' : '',
isImmersiveToolId(activeTool.id) ? 'app-shell--immersive-tool' : '',
`app-shell--nav-${settings.layout.navigationStyle}`
].filter(Boolean).join(' ')
@@ -0,0 +1,10 @@
import { describe, expect, it } from 'vitest'
import { toolIds } from '@/app/toolRegistry'
import { isImmersiveToolId } from './immersiveTools'
describe('immersive tool classification', () => {
it('keeps home unchanged and makes every functional tool immersive', () => {
expect(isImmersiveToolId('mootool')).toBe(false)
expect(toolIds.filter((toolId) => toolId !== 'mootool').every(isImmersiveToolId)).toBe(true)
})
})
@@ -0,0 +1,5 @@
import type { ToolId } from '@/app/toolRegistry'
export function isImmersiveToolId(toolId: ToolId): boolean {
return toolId !== 'mootool'
}
+5 -1
View File
@@ -1,5 +1,5 @@
import { X } from 'lucide-react'
import { useEffect, useId, type ReactNode } from 'react'
import { useEffect, useId, useRef, type ReactNode } from 'react'
import { createPortal } from 'react-dom'
import { useToolActivity } from './ToolActivity'
@@ -14,12 +14,15 @@ type DialogProps = {
export function Dialog({ title, open, onClose, children, footer, width = 640 }: DialogProps) {
const titleId = useId()
const dialogRef = useRef<HTMLElement>(null)
const toolActive = useToolActivity()
const visible = open && toolActive
useEffect(() => {
if (!visible) return
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') {
const dialogs = document.querySelectorAll<HTMLElement>('[role="dialog"][aria-modal="true"]')
if (dialogs.item(dialogs.length - 1) !== dialogRef.current) return
event.preventDefault()
onClose()
}
@@ -33,6 +36,7 @@ export function Dialog({ title, open, onClose, children, footer, width = 640 }:
return createPortal(
<div className="dialog-backdrop" role="presentation" onMouseDown={onClose}>
<section
ref={dialogRef}
className="dialog"
role="dialog"
aria-modal="true"
+2 -2
View File
@@ -6,8 +6,8 @@ import { useI18n } from '@/shared/i18n/I18nProvider'
export function ToolPageHeader({ title, actions }: { title: string; actions?: ReactNode }) {
return (
<div className="tool-page__header">
<h1>{title}</h1>
<div className={actions ? 'tool-page__header tool-page__header--actions' : 'tool-page__header tool-page__header--semantic'}>
<h1 className="visually-hidden">{title}</h1>
{actions && <div className="tool-header-actions">{actions}</div>}
</div>
)
@@ -0,0 +1,150 @@
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState, type ReactNode } from 'react'
import { Dialog } from '@/shared/components/Dialog'
import { useI18n } from '@/shared/i18n/I18nProvider'
type DialogOptions = {
title?: string
confirmLabel?: string
danger?: boolean
}
type PromptOptions = DialogOptions & {
defaultValue?: string
}
type ConfirmRequest = {
id: number
type: 'confirm'
message: string
options: DialogOptions
resolve: (value: boolean) => void
}
type PromptRequest = {
id: number
type: 'prompt'
message: string
options: PromptOptions
value: string
resolve: (value: string | null) => void
}
type DesktopDialogRequest = ConfirmRequest | PromptRequest
type DesktopDialogApi = {
confirm: (message: string, options?: DialogOptions) => Promise<boolean>
prompt: (message: string, options?: PromptOptions) => Promise<string | null>
}
const DesktopDialogContext = createContext<DesktopDialogApi | null>(null)
export function DesktopDialogProvider({ children }: { children: ReactNode }) {
const { t } = useI18n()
const [request, setRequest] = useState<DesktopDialogRequest | null>(null)
const requestRef = useRef<DesktopDialogRequest | null>(null)
const queueRef = useRef<DesktopDialogRequest[]>([])
const nextIdRef = useRef(1)
const display = useCallback((nextRequest: DesktopDialogRequest) => {
if (requestRef.current) {
queueRef.current.push(nextRequest)
return
}
requestRef.current = nextRequest
setRequest(nextRequest)
}, [])
const confirm = useCallback((message: string, options: DialogOptions = {}) => new Promise<boolean>((resolve) => {
display({ id: nextIdRef.current++, type: 'confirm', message, options, resolve })
}), [display])
const prompt = useCallback((message: string, options: PromptOptions = {}) => new Promise<string | null>((resolve) => {
display({ id: nextIdRef.current++, type: 'prompt', message, options, value: options.defaultValue ?? '', resolve })
}), [display])
const settle = useCallback((value?: boolean | string | null) => {
const current = requestRef.current
if (!current) return
if (current.type === 'confirm') current.resolve(value === true)
else current.resolve(typeof value === 'string' ? value : null)
const nextRequest = queueRef.current.shift() ?? null
requestRef.current = nextRequest
setRequest(nextRequest)
}, [])
useEffect(() => () => {
const pending = [requestRef.current, ...queueRef.current].filter((item): item is DesktopDialogRequest => Boolean(item))
pending.forEach((item) => {
if (item.type === 'confirm') item.resolve(false)
else item.resolve(null)
})
requestRef.current = null
queueRef.current = []
}, [])
const api = useMemo<DesktopDialogApi>(() => ({ confirm, prompt }), [confirm, prompt])
const title = request?.options.title ?? (request?.type === 'prompt' ? request.message : t('common.confirm'))
const confirmLabel = request?.options.confirmLabel ?? (request?.type === 'prompt' ? t('common.action.save') : t('common.yes'))
return (
<DesktopDialogContext.Provider value={api}>
{children}
<Dialog
title={title}
open={Boolean(request)}
width={420}
onClose={() => settle(null)}
footer={request && (
<>
<button className="dialog-button" type="button" onClick={() => settle(null)}>{t('common.cancel')}</button>
<button
className={request.options.danger ? 'dialog-button dialog-button--danger desktop-dialog__confirm' : 'dialog-button dialog-button--primary'}
type="button"
autoFocus={request.type === 'confirm'}
onClick={() => settle(request.type === 'prompt' ? request.value : true)}
>
{confirmLabel}
</button>
</>
)}
>
{request?.type === 'confirm' ? (
<p className="desktop-dialog__message">{request.message}</p>
) : request?.type === 'prompt' ? (
<label className="dialog-field desktop-dialog__field">
<span className="visually-hidden">{request.message}</span>
<input
key={request.id}
autoFocus
value={request.value}
aria-label={request.message}
onFocus={(event) => event.currentTarget.select()}
onChange={(event) => {
const value = event.target.value
setRequest((current) => {
if (!current || current.type !== 'prompt') return current
const nextRequest = { ...current, value }
requestRef.current = nextRequest
return nextRequest
})
}}
onKeyDown={(event) => {
if (event.key === 'Enter' && !event.nativeEvent.isComposing) {
event.preventDefault()
settle(requestRef.current?.type === 'prompt' ? requestRef.current.value : null)
}
}}
/>
</label>
) : null}
</Dialog>
</DesktopDialogContext.Provider>
)
}
export function useDesktopDialog(): DesktopDialogApi {
const context = useContext(DesktopDialogContext)
if (!context) throw new Error('useDesktopDialog must be used within DesktopDialogProvider')
return context
}
+3
View File
@@ -19,6 +19,7 @@ export const messages = {
'common.loading': '正在加载…',
'common.close': '关闭',
'common.cancel': '取消',
'common.confirm': '确认操作',
'findReplace.find': '查找',
'findReplace.findPlaceholder': '查找',
'findReplace.replacePlaceholder': '替换为',
@@ -1252,6 +1253,7 @@ export const messages = {
'common.loading': 'Loading…',
'common.close': 'Close',
'common.cancel': 'Cancel',
'common.confirm': 'Confirm action',
'findReplace.find': 'Find',
'findReplace.findPlaceholder': 'Find',
'findReplace.replacePlaceholder': 'Replace with',
@@ -2485,6 +2487,7 @@ export const messages = {
'common.loading': '読み込み中…',
'common.close': '閉じる',
'common.cancel': 'キャンセル',
'common.confirm': '操作の確認',
'findReplace.find': '検索',
'findReplace.findPlaceholder': '検索',
'findReplace.replacePlaceholder': '置換文字列',
+197
View File
@@ -1944,6 +1944,11 @@ select:not([multiple]) {
width: var(--navigation-width);
}
.app-shell--immersive-tool > .window-drag-region {
right: auto;
width: var(--navigation-width);
}
.sidebar {
position: relative;
display: flex;
@@ -7591,6 +7596,63 @@ select:not([multiple]) {
}
}
/* JSON, HTTP and QR code have tool-specific edge-to-edge workspace details. */
.tool-page.tool-page--workspace {
position: relative;
padding: 0;
background: var(--surface);
}
:root .tool-page.tool-page--workspace > .local-tool-shell,
:root .tool-page.tool-page--workspace .editor-shell,
:root .tool-page.tool-page--workspace .vault-panel,
:root .tool-page.tool-page--workspace .inspector-panel {
border-radius: 0;
box-shadow: none;
}
.tool-page.tool-page--workspace > .local-tool-shell {
border: 0;
}
.json-tool.tool-page--workspace .json-layout {
gap: 0;
}
.json-tool.tool-page--workspace .vault-panel {
border-width: 0 1px 0 0;
}
.json-tool.tool-page--workspace .editor-shell {
border-width: 0 1px 0 0;
}
.json-tool.tool-page--workspace .inspector-panel {
border-width: 0;
}
.json-statusbar {
display: flex;
min-height: 24px;
align-items: center;
justify-content: flex-end;
gap: 5px;
padding: 0 10px;
color: var(--text-muted);
background: var(--toolbar);
border-top: 1px solid var(--border-soft);
font-size: 10px;
line-height: 1;
}
.json-statusbar--valid {
color: var(--success-text);
}
.json-statusbar--error {
color: var(--error-text);
}
@media (max-width: 1320px) {
.app-shell {
grid-template-columns: 232px 1fr;
@@ -11685,3 +11747,138 @@ textarea:focus {
border: 1px solid color-mix(in srgb, var(--claude-accent) 18%, transparent);
border-radius: 999px;
}
/* Immersive tools keep window mechanics at the edges instead of reserving
* visible chrome above the workspace. */
.tool-view-shell--immersive > .window-drag-region {
right: 0;
height: 6px;
}
.tool-view-shell--immersive .tool-window-toggle-slot {
top: 50%;
right: 0;
width: 7px;
height: 64px;
align-items: center;
justify-content: flex-end;
transform: translateY(-50%);
transition: width 140ms ease;
}
.tool-view-shell--immersive .tool-window-toggle-slot > .tooltip-trigger {
position: absolute;
right: 8px;
opacity: 0;
pointer-events: none;
transform: translateX(10px) scale(0.96);
transition: opacity 120ms ease, transform 140ms ease;
}
.tool-view-shell--immersive .tool-window-toggle-slot:hover,
.tool-view-shell--immersive .tool-window-toggle-slot:focus-within {
width: 52px;
}
.tool-view-shell--immersive .tool-window-toggle-slot:hover > .tooltip-trigger,
.tool-view-shell--immersive .tool-window-toggle-slot:focus-within > .tooltip-trigger {
opacity: 1;
pointer-events: auto;
transform: translateX(0) scale(1);
}
:root .tool-view-shell--immersive .tool-window-toggle {
color: var(--text-body);
background: color-mix(in srgb, var(--surface-elevated) 88%, transparent);
border: 1px solid var(--border-control);
border-radius: 9px;
box-shadow: var(--shadow-popover);
backdrop-filter: blur(14px);
}
@media (prefers-reduced-motion: reduce) {
.tool-view-shell--immersive .tool-window-toggle-slot,
.tool-view-shell--immersive .tool-window-toggle-slot > .tooltip-trigger {
transition: none;
}
}
/* All functional tools use their window as the workspace. The home page keeps
* its existing composition because it does not use the .tool-page root. */
.visually-hidden {
position: absolute !important;
width: 1px !important;
height: 1px !important;
padding: 0 !important;
margin: -1px !important;
overflow: hidden !important;
clip: rect(0, 0, 0, 0) !important;
white-space: nowrap !important;
border: 0 !important;
}
:root .tool-page {
position: relative;
padding: 0;
background: var(--surface);
}
:root .tool-page__header--semantic {
position: absolute;
width: 1px;
height: 1px;
margin: 0;
overflow: hidden;
pointer-events: none;
}
:root .tool-page__header--actions {
min-height: 42px;
justify-content: flex-end;
gap: 8px;
margin: 0;
padding: 5px 10px;
background: var(--toolbar);
border-bottom: 1px solid var(--border-soft);
}
:root[data-interface-style] .tool-page__header--actions {
margin: 0;
}
:root .tool-page__header--actions > :not(.visually-hidden) {
margin-left: auto;
}
:root .tool-page > .local-tool-shell,
:root .tool-page > .runtime-shell,
:root .tool-page .editor-shell,
:root .tool-page .vault-panel,
:root .tool-page .inspector-panel,
:root .tool-page .quick-note-editor-shell,
:root .tool-page .quick-note-sidebar,
:root .tool-page .quick-replace-panel {
border-radius: 0;
box-shadow: none;
}
:root .tool-page > .local-tool-shell,
:root .tool-page > .runtime-shell {
border: 0;
}
.desktop-dialog__message {
margin: 0;
color: var(--text-body);
font-size: 13px;
line-height: 1.6;
white-space: pre-wrap;
}
.desktop-dialog__field {
padding-top: 2px;
}
.dialog__footer .desktop-dialog__confirm.dialog-button--danger {
margin-right: 0;
}
+41 -22
View File
@@ -354,8 +354,10 @@ test('creates and persists custom navigation groups', async () => {
await mainPage.getByRole('button', { name: '管理分组', exact: true }).click()
await dialog.locator('.custom-group-manager__group').filter({ hasText: '开发常用' }).click()
mainPage.once('dialog', (confirmation) => confirmation.accept())
await dialog.getByRole('button', { name: '删除分组', exact: true }).click()
const deleteGroupDialog = mainPage.getByRole('dialog', { name: '确认操作' })
await expect(deleteGroupDialog).toContainText('确定删除分组“开发常用”吗?')
await deleteGroupDialog.getByRole('button', { name: '删除分组', exact: true }).click()
await dialog.getByRole('button', { name: '保存', exact: true }).click()
await expect(mainPage.locator('.tool-group--custom')).toHaveCount(0)
await expect.poll(() => mainPage.evaluate(() => window.mootool.getSettings())).toMatchObject({ layout: { customGroups: [] } })
@@ -459,10 +461,17 @@ test('creates and presents an auto-fitting message board', async () => {
test('formats JSON and completes history and Vault workflows', async () => {
await mainPage.locator('.tool-button').filter({ hasText: 'JSON' }).click()
await expect.poll(() => mainPage.locator('.workspace-tool-session:not([hidden]) .tool-page').evaluate((element) => {
const activeJsonPage = mainPage.locator('.workspace-tool-session:not([hidden]) .tool-page')
await expect.poll(() => activeJsonPage.evaluate((element) => {
const style = getComputedStyle(element)
return { top: style.paddingTop, bottom: style.paddingBottom }
})).toEqual({ top: '20px', bottom: '20px' })
})).toEqual({ top: '0px', bottom: '0px' })
await expect(activeJsonPage).toHaveAttribute('aria-label', 'JSON 工作台')
await expect(activeJsonPage.locator('h1')).toHaveCount(0)
await expect.poll(() => mainPage.locator('.editor-shell').evaluate((element) => {
const style = getComputedStyle(element)
return { borderRadius: style.borderRadius, boxShadow: style.boxShadow }
})).toEqual({ borderRadius: '0px', boxShadow: 'none' })
const jsonVault = mainPage.locator('.vault-panel')
await expect(mainPage.locator('.vault-panel__header h2')).toHaveCSS('font-size', '14px')
await expect(mainPage.locator('.vault-panel__options > select')).toHaveCSS('font-size', '12px')
@@ -528,9 +537,10 @@ test('formats JSON and completes history and Vault workflows', async () => {
await mainPage.evaluate(() => window.mootool.updateSettings({ editor: { jsonFontSize: 22 } }))
await expect(mainPage.locator('.json-editor')).toHaveCSS('font-size', '22px')
const jsonLineOffsets = await editorLineTopOffsets(mainPage, '.json-editor')
expect(jsonLineOffsets).toEqual(expect.arrayContaining([expect.any(Number)]))
expect(Math.max(...jsonLineOffsets)).toBeLessThan(1.5)
await expect.poll(async () => {
const offsets = await editorLineTopOffsets(mainPage, '.json-editor')
return offsets.length ? Math.max(...offsets) : Number.POSITIVE_INFINITY
}).toBeLessThan(1.5)
await mainPage.getByLabel('字体').selectOption('Georgia')
await expect.poll(() => mainPage.locator('.json-editor').evaluate((element) => (element as HTMLElement).style.getPropertyValue('--text-code-editor-font-family'))).toContain('Georgia')
await expect.poll(() => mainPage.locator('.json-editor .cm-scroller').evaluate((element) => getComputedStyle(element).fontFamily)).toMatch(/Georgia/)
@@ -551,9 +561,9 @@ test('formats JSON and completes history and Vault workflows', async () => {
await expect.poll(() => vaultNode.locator('svg').evaluate((icon) => icon.getBoundingClientRect().width)).toBe(16)
await editor.fill('{"saved":true}')
await expect.poll(() => editor.evaluate((element) => (element as HTMLElement).innerText)).toBe('{"saved":true}')
await mainPage.getByRole('button', { name: '保存片段' }).click()
const vaultFile = await mainPage.evaluate(() => window.mootool.readJsonVaultFile('e2e-sample.json'))
expect(vaultFile.content).toBe('{"saved":true}')
await expect.poll(() => mainPage.evaluate(async () => (await window.mootool.readJsonVaultFile('e2e-sample.json')).content)).toBe('{"saved":true}')
const vaultSearch = jsonVault.getByRole('textbox', { name: '搜索', exact: true })
const vaultSearchContent = jsonVault.getByRole('checkbox', { name: '搜索正文' })
await vaultSearch.fill('"saved"')
@@ -565,8 +575,10 @@ test('formats JSON and completes history and Vault workflows', async () => {
await vaultSearch.fill('')
await vaultSearchContent.check()
mainPage.once('dialog', (dialog) => dialog.accept())
await mainPage.getByRole('button', { name: '删除片段' }).click()
const deleteVaultDialog = mainPage.getByRole('dialog', { name: '确认操作' })
await expect(deleteVaultDialog).toContainText('确定删除')
await deleteVaultDialog.getByRole('button', { name: '删除', exact: true }).click()
await expect(mainPage.locator('.vault-node').filter({ hasText: 'e2e-sample.json' })).toHaveCount(0)
})
@@ -596,8 +608,8 @@ test('manages JSON Vault folders, rename, duplicate, and move workflows', async
await mainPage.getByRole('button', { name: '保存', exact: true }).click()
await expect(mainPage.locator('.vault-tree > div > .vault-node').filter({ hasText: 'renamed Copy.json' })).toBeVisible()
mainPage.once('dialog', (dialog) => dialog.accept())
await mainPage.getByRole('button', { name: '删除片段' }).click()
await mainPage.getByRole('dialog', { name: '确认操作' }).getByRole('button', { name: '删除', exact: true }).click()
await expect(mainPage.locator('.vault-node').filter({ hasText: 'renamed Copy.json' })).toHaveCount(0)
})
@@ -680,7 +692,7 @@ test('opens the settings window and synchronizes appearance changes', async () =
await expect(jsonNavigationToggle).toBeChecked()
await jsonNavigationToggle.uncheck()
await expect.poll(() => settingsPage.evaluate(() => window.mootool.getSettings())).toMatchObject({ layout: { hiddenNavigationToolIds: ['json'] } })
await expect(mainPage.getByRole('button', { name: 'JSON', exact: true })).toHaveCount(0)
await expect(mainPage.locator('.tool-button').filter({ hasText: 'JSON' })).toHaveCount(0)
await settingsPage.locator('.settings-nav__item').filter({ hasText: '运行环境' }).click()
const runtimeGroup = settingsPage.locator('.runtime-settings-group')
@@ -857,8 +869,6 @@ test('runs P3 regex, Cron and text Diff workflows with persistent favorites', as
clipboard.setData('text/plain', 'one\ntwo\n')
element.dispatchEvent(new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData: clipboard }))
})
await mainPage.evaluate(() => new Promise<void>((resolve) => requestAnimationFrame(() => resolve())))
expect(await mainPage.locator('.diff-status').textContent()).toContain('共发现 0 处差异')
await expect(mainPage.locator('.diff-status')).toContainText('共发现 0 处差异')
await expect(mainPage.locator('.diff-editor-grid [class*="cm-diff-"]')).toHaveCount(0)
await diffEditors.nth(1).focus()
@@ -976,6 +986,12 @@ test('runs P4 color favorites, image persistence and PDF workspace', async () =>
await openTool('图片助手', '图片助手')
await expect(mainPage.locator('.image-list-item').filter({ hasText: 'e2e-image.png' })).toBeVisible()
await expect(mainPage.locator('.image-canvas img')).toBeVisible()
await mainPage.getByRole('button', { name: '重命名', exact: true }).click()
const renameImageDialog = mainPage.getByRole('dialog', { name: '输入新的图片名称' })
await expect(renameImageDialog.getByRole('textbox', { name: '输入新的图片名称' })).toHaveValue('e2e-image.png')
await renameImageDialog.getByRole('textbox', { name: '输入新的图片名称' }).fill('e2e-image-renamed.png')
await renameImageDialog.getByRole('button', { name: '重命名', exact: true }).click()
await expect(mainPage.locator('.image-list-item').filter({ hasText: 'e2e-image-renamed.png' })).toBeVisible()
await mainPage.getByRole('button', { name: '复制图片' }).click()
expect(await mainPage.evaluate(() => window.mootool.readClipboardImage())).toMatch(/^data:image\/png;base64,/)
@@ -1088,8 +1104,8 @@ test('runs P5 Hosts, translation records, network and system workflows', async (
const renamedHostProfile = mainPage.locator('.host-profile').filter({ hasText: 'E2E renamed host' })
await expect(renamedHostProfile).toBeVisible()
await renamedHostProfile.click({ button: 'right' })
mainPage.once('dialog', (dialog) => dialog.accept())
await mainPage.getByRole('menu', { name: 'Host 方案操作' }).getByRole('menuitem', { name: '删除' }).click()
await mainPage.getByRole('dialog', { name: '确认操作' }).getByRole('button', { name: '删除', exact: true }).click()
await expect(renamedHostProfile).toHaveCount(0)
await mainPage.evaluate(() => window.mootool.saveTranslationWord({
@@ -1176,13 +1192,14 @@ test('runs P6 Quick Note Vault, Markdown preview and Git workflows', async () =>
await expect(colorMenu).toHaveCount(0)
const editor = mainPage.getByLabel('笔记内容')
await editor.fill('## E2E Markdown\n\n- Vault file\n- Git checkpoint')
await expect(mainPage.locator('.quick-note-code-editor .cm-activeLine')).toHaveCount(1)
await expect(mainPage.locator('.quick-note-code-editor .cm-activeLineGutter')).toHaveCount(1)
await expect.poll(() => mainPage.locator('.quick-note-code-editor .cm-activeLine').count()).toBeGreaterThanOrEqual(1)
await expect.poll(() => mainPage.locator('.quick-note-code-editor .cm-activeLineGutter').count()).toBeGreaterThanOrEqual(1)
await mainPage.getByLabel('字号').fill('24')
await expect(mainPage.locator('.quick-note-code-editor')).toHaveCSS('font-size', '24px')
const quickNoteLineOffsets = await editorLineTopOffsets(mainPage, '.quick-note-code-editor')
expect(quickNoteLineOffsets).toEqual(expect.arrayContaining([expect.any(Number)]))
expect(Math.max(...quickNoteLineOffsets)).toBeLessThan(1.5)
await expect.poll(async () => {
const offsets = await editorLineTopOffsets(mainPage, '.quick-note-code-editor')
return offsets.length ? Math.max(...offsets) : Number.POSITIVE_INFINITY
}).toBeLessThan(1.5)
await mainPage.getByRole('button', { name: '查找与替换' }).click()
await mainPage.getByRole('textbox', { name: '查找', exact: true }).fill('Vault')
await expect(mainPage.locator('.quick-note-code-editor .cm-searchMatch')).toHaveCount(1)
@@ -1685,8 +1702,8 @@ async function editorLineTopOffsets(page: Page, rootSelector: string): Promise<n
async function expectTextEditorChrome(editor: Locator): Promise<void> {
await expect(editor.locator('.cm-lineNumbers')).toHaveCount(1)
await expect(editor.locator('.cm-activeLine')).toHaveCount(1)
await expect(editor.locator('.cm-activeLineGutter')).toHaveCount(1)
await expect.poll(() => editor.locator('.cm-activeLine').count()).toBeGreaterThanOrEqual(1)
await expect.poll(() => editor.locator('.cm-activeLineGutter').count()).toBeGreaterThanOrEqual(1)
}
async function expectSelectedOptionToFit(select: Locator): Promise<void> {
@@ -1723,5 +1740,7 @@ async function openTool(label: string, title: string): Promise<void> {
const button = mainPage.locator('.tool-button').filter({ hasText: label }).first()
await button.scrollIntoViewIfNeeded()
await button.click()
await expect(mainPage.locator('.workspace-tool-session:not([hidden]) .tool-page h1')).toHaveText(title)
await expect.poll(() => mainPage.locator('.workspace-tool-session:not([hidden]) .tool-page').evaluate((element) => {
return element.querySelector('h1')?.textContent ?? element.getAttribute('aria-label')
})).toBe(title)
}
@@ -0,0 +1,78 @@
import { _electron as electron, expect, test, type ElectronApplication, type Page } from '@playwright/test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
let electronApp: ElectronApplication
let mainPage: Page
let userDataDirectory: string
test.beforeAll(async () => {
userDataDirectory = await mkdtemp(join(tmpdir(), 'mootool-immersive-e2e-'))
electronApp = await electron.launch({
args: ['.', `--user-data-dir=${userDataDirectory}`],
cwd: process.cwd(),
env: { ...process.env, NODE_ENV: 'test', MOOTOOL_TOOL_VIEWS: '0' }
})
mainPage = await electronApp.firstWindow()
await mainPage.waitForLoadState('domcontentloaded')
})
test.afterAll(async () => {
await electronApp.close()
await rm(userDataDirectory, { recursive: true, force: true })
})
test('renders every functional tool as an immersive workspace while leaving home unchanged', async () => {
const toolLabels = [
'随手记', '文本对比', '格式化', 'JSON', '代码运行', '配置文件转换', 'Protobuf', '环境变量',
'HTTP 请求', 'Host', '网络/IP', 'UA 分析', '编码解码', '加解密/随机', '正则', 'Cron', '二维码',
'时间转换', '留言板', '翻译', '计算器', '调色板', '图片助手', 'PDF', '系统信息'
]
await mainPage.getByRole('button', { name: '主页', exact: true }).click()
await expect(mainPage.locator('.home-page h1')).toBeVisible()
await expect(mainPage.locator('.app-shell')).not.toHaveClass(/app-shell--immersive-tool/)
for (const label of toolLabels) {
const button = mainPage.locator('.tool-button').filter({ hasText: label }).first()
await button.scrollIntoViewIfNeeded()
await button.click()
const page = mainPage.locator('.workspace-tool-session:not([hidden]) .tool-page')
await expect(page).toBeVisible()
await expect(mainPage.locator('.app-shell')).toHaveClass(/app-shell--immersive-tool/)
await expect.poll(() => page.evaluate((element) => {
const bounds = element.getBoundingClientRect()
const workspace = element.closest('.workspace')?.getBoundingClientRect()
const style = getComputedStyle(element)
const title = element.querySelector(':scope > .tool-page__header h1')
const titleBounds = title?.getBoundingClientRect()
const actionHeader = element.querySelector(':scope > .tool-page__header--actions')
const actionControls = actionHeader ? [...actionHeader.querySelectorAll<HTMLElement>('button, input, select')] : []
const outerSurface = element.querySelector<HTMLElement>(':scope > .local-tool-shell, :scope > .runtime-shell')
const outerStyle = outerSurface ? getComputedStyle(outerSurface) : null
return {
topAligned: workspace ? Math.abs(bounds.top - workspace.top) < 0.5 : false,
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
visiblePageTitle: Boolean(titleBounds && titleBounds.width > 1 && titleBounds.height > 1),
actionHeaderFlush: !actionHeader || getComputedStyle(actionHeader).marginBottom === '0px',
actionControlsVisible: actionControls.every((control) => {
const rect = control.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}),
outerSurfaceFlat: !outerStyle || (outerStyle.borderRadius === '0px' && outerStyle.boxShadow === 'none')
}
})).toEqual({
topAligned: true,
padding: ['0px', '0px', '0px', '0px'],
visiblePageTitle: false,
actionHeaderFlush: true,
actionControlsVisible: true,
outerSurfaceFlat: true
})
}
await mainPage.getByRole('button', { name: '主页', exact: true }).click()
await expect(mainPage.locator('.home-page h1')).toBeVisible()
await expect(mainPage.locator('.app-shell')).not.toHaveClass(/app-shell--immersive-tool/)
})
+100 -80
View File
@@ -47,14 +47,17 @@ test('moves one live tool view into a separate window and restores it without lo
&& dragRegion.getBoundingClientRect().right <= workspace.getBoundingClientRect().left
})).toBe(true)
await expect.poll(async () => (await evaluateTool<boolean>('calculator', `(() => {
const shell = document.querySelector('.tool-view-shell')
const dragRegion = document.querySelector('.window-drag-region').getBoundingClientRect()
const button = document.querySelector('.tool-window-toggle').getBoundingClientRect()
return dragRegion.right <= button.left
const slot = document.querySelector('.tool-window-toggle-slot').getBoundingClientRect()
return shell?.classList.contains('tool-view-shell--immersive')
&& Math.round(dragRegion.height) === 6
&& Math.round(slot.width) === 7
})()`)).value).toBe(true)
await clickToolWindowToggle('calculator')
await expect(mainPage.getByRole('heading', { name: '计算器 已在独立窗口中打开' })).toBeVisible()
await expect.poll(() => getToolSnapshot('calculator')).toMatchObject({ detached: true, ready: true })
await expect(mainPage.getByRole('heading', { name: '计算器 已在独立窗口中打开' })).toBeVisible()
await expect.poll(() => getBaseWindowCount()).toBe(2)
await expect.poll(() => getDetachedWindowChrome()).toMatchObject({
contentInsetTop: 0,
@@ -87,41 +90,52 @@ test('keeps multiple detached tools independent and returns each one to its dock
await evaluateTool('json', `document.querySelector('.tool-window-toggle').click()`)
await expect.poll(() => getToolSnapshot('json')).toMatchObject({ detached: true, ready: true })
await expect.poll(async () => (await evaluateTool('json', `(() => {
const logo = document.querySelector('.tool-window-brand')
const title = document.querySelector('.tool-page__header h1')
const zone = document.querySelector('.tool-window-brand-zone')
if (!logo || !title || !zone) return null
const logoBounds = logo.getBoundingClientRect()
const titleBounds = title.getBoundingClientRect()
const zoneBounds = zone.getBoundingClientRect()
const zoneStyle = getComputedStyle(zone)
const shell = document.querySelector('.tool-view-shell')
const page = document.querySelector('.json-tool')
const drag = document.querySelector('.window-drag-region')
const slot = document.querySelector('.tool-window-toggle-slot')
const trigger = slot?.querySelector('.tooltip-trigger')
if (!shell || !page || !drag || !slot || !trigger) return null
const pageBounds = page.getBoundingClientRect()
const dragBounds = drag.getBoundingClientRect()
const slotBounds = slot.getBoundingClientRect()
const triggerStyle = getComputedStyle(trigger)
return {
title: title.textContent,
logoBeforeTitle: logoBounds.right < titleBounds.left,
logoVisible: logoBounds.width > 0 && logoBounds.height > 0,
logoLoaded: logo instanceof HTMLImageElement && logo.complete && logo.naturalWidth > 0,
logoLeft: Math.round(logoBounds.left),
titleLeft: Math.round(titleBounds.left),
sameRow: Math.abs((logoBounds.top + logoBounds.height / 2) - (titleBounds.top + titleBounds.height / 2)) < 2,
headerTop: Math.round(zoneBounds.top),
controlsHidden: zone.getAttribute('data-window-controls-visible') === 'false',
hasVisualOverlay: zoneStyle.backgroundImage !== 'none'
|| zoneStyle.boxShadow !== 'none'
|| String(zoneStyle.backdropFilter || zoneStyle.getPropertyValue('-webkit-backdrop-filter')) !== 'none'
immersive: shell.classList.contains('tool-view-shell--immersive'),
accessibleName: page.getAttribute('aria-label'),
visibleTitleCount: page.querySelectorAll('h1').length,
brandCount: shell.querySelectorAll('.tool-window-brand-zone').length,
pageTop: Math.round(pageBounds.top),
dragHeight: Math.round(dragBounds.height),
edgeSlotWidth: Math.round(slotBounds.width),
toggleOpacity: triggerStyle.opacity,
togglePointerEvents: triggerStyle.pointerEvents
}
})()`)).value).toEqual({
title: 'JSON 工作台',
logoBeforeTitle: true,
logoVisible: true,
logoLoaded: true,
logoLeft: 20,
titleLeft: 54,
sameRow: true,
headerTop: 18,
controlsHidden: true,
hasVisualOverlay: false
immersive: true,
accessibleName: 'JSON 工作台',
visibleTitleCount: 0,
brandCount: 0,
pageTop: 0,
dragHeight: 6,
edgeSlotWidth: 7,
toggleOpacity: '0',
togglePointerEvents: 'none'
})
await evaluateTool('json', `document.querySelector('.tool-window-toggle').focus()`)
await expect.poll(async () => (await evaluateTool('json', `(() => {
const slot = document.querySelector('.tool-window-toggle-slot')
const trigger = slot?.querySelector('.tooltip-trigger')
if (!slot || !trigger) return null
return {
edgeSlotWidth: Math.round(slot.getBoundingClientRect().width),
toggleOpacity: getComputedStyle(trigger).opacity,
togglePointerEvents: getComputedStyle(trigger).pointerEvents
}
})()`)).value).toEqual({ edgeSlotWidth: 52, toggleOpacity: '1', togglePointerEvents: 'auto' })
await evaluateTool('json', `document.querySelector('.tool-window-toggle').blur()`)
await mainPage.getByRole('button', { name: 'HTTP 请求', exact: true }).click()
await waitForToolSelector('http', '.http-tool-page')
await evaluateTool('http', `document.querySelector('.tool-window-toggle').click()`)
@@ -138,77 +152,68 @@ test('keeps multiple detached tools independent and returns each one to its dock
await expect.poll(() => getBaseWindowCount()).toBe(1)
})
test('aligns detached tool branding, header controls, and dock action in one top row', async () => {
test('keeps a custom-header tool immersive while preserving its view controls', async () => {
await mainPage.getByRole('button', { name: '随手记', exact: true }).click()
await waitForToolSelector('quickNote', '.quick-note-tool')
await evaluateTool('quickNote', `document.querySelector('.tool-window-toggle').click()`)
await expect.poll(() => getToolSnapshot('quickNote')).toMatchObject({ detached: true, ready: true })
await expect.poll(async () => (await evaluateTool('quickNote', `(() => {
const bounds = (selector) => document.querySelector(selector)?.getBoundingClientRect()
const header = bounds('.quick-note-page-header')
const zone = bounds('.tool-window-brand-zone')
const logo = bounds('.tool-window-brand')
const title = bounds('.quick-note-page-header h1')
const switcher = bounds('.quick-note-view-switch')
const dock = bounds('.tool-window-toggle')
if (!header || !zone || !logo || !title || !switcher || !dock) return null
const centerY = (rect) => rect.top + rect.height / 2
const shell = document.querySelector('.tool-view-shell')
const page = document.querySelector('.quick-note-tool')
const header = document.querySelector('.quick-note-page-header')?.getBoundingClientRect()
const title = document.querySelector('.quick-note-page-header h1')?.getBoundingClientRect()
const switcher = document.querySelector('.quick-note-view-switch')?.getBoundingClientRect()
const drag = document.querySelector('.window-drag-region')?.getBoundingClientRect()
const slot = document.querySelector('.tool-window-toggle-slot')?.getBoundingClientRect()
if (!shell || !page || !header || !title || !switcher || !drag || !slot) return null
return {
immersive: shell.classList.contains('tool-view-shell--immersive'),
brandCount: shell.querySelectorAll('.tool-window-brand-zone').length,
pageTop: Math.round(page.getBoundingClientRect().top),
headerTop: Math.round(header.top),
brandTop: Math.round(zone.top),
dockTop: Math.round(dock.top),
logoAligned: Math.abs(centerY(logo) - centerY(dock)) <= 2,
titleAligned: Math.abs(centerY(title) - centerY(dock)) <= 2,
switcherAligned: Math.abs(centerY(switcher) - centerY(dock)) <= 2,
controlsHidden: document.querySelector('.tool-window-brand-zone').getAttribute('data-window-controls-visible') === 'false',
logoLeft: Math.round(logo.left),
brandDraggable: getComputedStyle(document.querySelector('.tool-window-brand-zone')).getPropertyValue('-webkit-app-region') === 'drag'
headerCompact: header.height >= 42 && header.height <= 48,
titleWidth: Math.round(title.width),
titleHeight: Math.round(title.height),
switchVisible: switcher.width > 0 && switcher.height > 0,
dragHeight: Math.round(drag.height),
edgeSlotWidth: Math.round(slot.width)
}
})()`)).value).toMatchObject({
headerTop: 18,
brandTop: 18,
dockTop: 18,
logoAligned: true,
titleAligned: true,
switcherAligned: true,
controlsHidden: true,
logoLeft: 20,
brandDraggable: true
immersive: true,
brandCount: 0,
pageTop: 0,
headerTop: 0,
headerCompact: true,
titleWidth: 1,
titleHeight: 1,
switchVisible: true,
dragHeight: 6,
edgeSlotWidth: 7
})
await sendToolWindowControlsVisibility('quickNote', true)
await expect.poll(async () => (await evaluateTool('quickNote', `(() => {
const zone = document.querySelector('.tool-window-brand-zone')
const logo = document.querySelector('.tool-window-brand').getBoundingClientRect()
const title = document.querySelector('.quick-note-page-header h1').getBoundingClientRect()
return {
controlsVisible: zone.getAttribute('data-window-controls-visible') === 'true',
logoLeft: Math.round(logo.left),
titleLeft: Math.round(title.left)
controlsClass: document.querySelector('.tool-view-shell').classList.contains('tool-view-shell--window-controls-visible'),
brandCount: document.querySelectorAll('.tool-window-brand-zone').length,
headerTop: Math.round(document.querySelector('.quick-note-page-header').getBoundingClientRect().top)
}
})()`)).value).toEqual({
controlsVisible: process.platform === 'darwin',
logoLeft: process.platform === 'darwin' ? 88 : 20,
titleLeft: process.platform === 'darwin' ? 122 : 54
controlsClass: true,
brandCount: 0,
headerTop: 0
})
await sendToolWindowControlsVisibility('quickNote', false)
await expect.poll(async () => (await evaluateTool('quickNote', `(() => {
const zone = document.querySelector('.tool-window-brand-zone')
const logo = document.querySelector('.tool-window-brand').getBoundingClientRect()
return {
controlsHidden: zone.getAttribute('data-window-controls-visible') === 'false',
logoLeft: Math.round(logo.left)
}
})()`)).value).toEqual({ controlsHidden: true, logoLeft: 20 })
await expect.poll(async () => (await evaluateTool('quickNote', `document.querySelector('.tool-view-shell').classList.contains('tool-view-shell--window-controls-visible')`)).value).toBe(false)
await closeDetachedBaseWindow()
await expect.poll(() => getToolSnapshot('quickNote')).toMatchObject({ detached: false, ready: true })
})
test('keeps double-clicked Quick Note split-preview text selected after auto-save', async () => {
await mainPage.getByRole('button', { name: '随手记', exact: true }).click()
await mainPage.locator('.tool-button').filter({ hasText: '随手记' }).click()
await waitForToolSelector('quickNote', '.quick-note-tool')
await evaluateTool('quickNote', `(() => {
const split = [...document.querySelectorAll('[role="tab"]')].find((tab) => tab.textContent === '分栏')
@@ -300,7 +305,12 @@ test('keeps message board presentation controls from overlapping', async () => {
gap: Math.round(dock.left - exit.right),
overlaps: exit.left < dock.right && exit.right > dock.left && exit.top < dock.bottom && exit.bottom > dock.top
}
})()`)).value).toEqual({ gap: 20, overlaps: false })
})()`)).value).toMatchObject({ overlaps: false })
await expect.poll(async () => (await evaluateTool<number>('messageBoard', `(() => {
const exit = document.querySelector('.message-board-stage__exit')?.getBoundingClientRect()
const dock = document.querySelector('.tool-window-toggle')?.getBoundingClientRect()
return exit && dock ? Math.round(dock.left - exit.right) : -1
})()`)).value).toBeGreaterThanOrEqual(12)
})
async function waitForTool(toolId: string): Promise<{ id: number; value: string }> {
@@ -337,6 +347,17 @@ async function clickToolWindowToggle(toolId: string): Promise<void> {
})
if (!contents) throw new Error(`Tool webContents not found: ${id}`)
const edgePoint = await contents.executeJavaScript(`(() => {
const slot = document.querySelector('.tool-window-toggle-slot')
if (!slot) throw new Error('Tool window toggle slot not found')
const rect = slot.getBoundingClientRect()
return { x: Math.max(0, Math.round(rect.right - 2)), y: Math.round(rect.top + rect.height / 2) }
})()`)
contents.focus()
contents.sendInputEvent({ type: 'mouseMove', x: edgePoint.x, y: edgePoint.y })
await new Promise((resolve) => setTimeout(resolve, 180))
const point = await contents.executeJavaScript(`(() => {
const button = document.querySelector('.tool-window-toggle')
if (!button) throw new Error('Tool window toggle not found')
@@ -344,7 +365,6 @@ async function clickToolWindowToggle(toolId: string): Promise<void> {
return { x: Math.round(rect.left + rect.width / 2), y: Math.round(rect.top + rect.height / 2) }
})()`)
contents.focus()
contents.sendInputEvent({ type: 'mouseMove', x: point.x, y: point.y })
contents.sendInputEvent({ type: 'mouseDown', x: point.x, y: point.y, button: 'left', clickCount: 1 })
contents.sendInputEvent({ type: 'mouseUp', x: point.x, y: point.y, button: 'left', clickCount: 1 })