mirror of
https://github.com/rememberber/MooTool.git
synced 2026-08-30 18:02:12 +08:00
[next]主布局样式调整,更app化,去web管理后台风格化
This commit is contained in:
@@ -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,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()
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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 && (
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,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 })}>
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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': '置換文字列',
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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/)
|
||||
})
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user