[next]给功能工作区单独根据自己本身实际内容定制可拖拽区域

This commit is contained in:
RememBerBer
2026-08-26 13:02:02 +08:00
parent 5efd8d4929
commit cbf5d7cd42
15 changed files with 121 additions and 34 deletions
+1 -1
View File
@@ -26,7 +26,7 @@ export function HardwareTool() {
<section className="tool-page p5-tool hardware-tool-page">
<ToolPageHeader title={t('hardware.title')} />
<div className="local-tool-shell hardware-workspace">
<header><ToolTabs tabs={sectionIds.map((id) => ({ id, label: t(`hardware.tab.${id}` as 'hardware.tab.system') }))} active={active} onChange={setActive} /><div><span>{snapshot ? new Date(snapshot.collectedAt).toLocaleTimeString() : ''}</span><button className="icon-button" type="button" aria-label={t('common.action.copy')} disabled={!groups.length} onClick={() => { void actions.copy(plainText) }}><ClipboardCopy size={14} /></button><button className="icon-button" type="button" aria-label={t('common.refresh')} disabled={loading} onClick={() => { void refresh() }}><RefreshCw size={14} className={loading ? 'spin' : undefined} /></button></div></header>
<header><ToolTabs tabs={sectionIds.map((id) => ({ id, label: t(`hardware.tab.${id}` as 'hardware.tab.system') }))} active={active} onChange={setActive} windowDrag /><div><span>{snapshot ? new Date(snapshot.collectedAt).toLocaleTimeString() : ''}</span><button className="icon-button" type="button" aria-label={t('common.action.copy')} disabled={!groups.length} onClick={() => { void actions.copy(plainText) }}><ClipboardCopy size={14} /></button><button className="icon-button" type="button" aria-label={t('common.refresh')} disabled={loading} onClick={() => { void refresh() }}><RefreshCw size={14} className={loading ? 'spin' : undefined} /></button></div></header>
<div className="hardware-groups">{loading && !snapshot ? <div className="history-empty">{t('hardware.loading')}</div> : groups.length === 0 ? <div className="history-empty">{t('hardware.empty')}</div> : groups.map((group, groupIndex) => <section className="hardware-group" key={`${group.title}-${groupIndex}`}><h2>{localizeHardware(group.title, t)}</h2><dl>{group.items.map((item, index) => <div key={`${item.label}-${index}`}><dt>{localizeHardware(item.label, t)}</dt><dd>{item.value}</dd></div>)}</dl></section>)}</div>
</div>
</section>
+2 -2
View File
@@ -4,7 +4,7 @@ import { createPortal } from 'react-dom'
import { Dialog } from '@/shared/components/Dialog'
import { FindReplaceBar } from '@/shared/components/FindReplaceBar'
import { ResizableColumns } from '@/shared/components/ResizableColumns'
import { ToolPageHeader } from '@/shared/components/ToolPage'
import { ToolPageHeader, WorkspaceDragZone } from '@/shared/components/ToolPage'
import { TextCodeEditor, type TextCodeEditorHandle } from '@/shared/components/TextCodeEditor'
import {
defaultFindReplaceOptions,
@@ -286,7 +286,7 @@ export function HostTool() {
<ToolPageHeader title={t('host.title')} />
<ResizableColumns className="local-tool-shell host-workspace" columns={2} defaultSizes={[220, 780]} minPaneWidths={[170, 360]} storageKey="host-workspace">
<aside className="host-profiles"><header><div className="compact-search"><Search size={13} /><input value={query} placeholder={t('common.search')} aria-label={t('common.search')} onChange={(event) => setQuery(event.target.value)} /></div><button className="icon-button" type="button" aria-label={t('common.new')} onClick={() => { void createProfile() }}><Plus size={14} /></button><label className="list-search-content host-search-content"><input type="checkbox" checked={includeContent} onChange={(event) => setIncludeContent(event.target.checked)} />{t('common.searchContent')}</label></header><div>{profiles.length === 0 ? <div className="history-empty">{t('host.empty')}</div> : profiles.map((profile) => <button className={profile.id === selected?.id ? 'host-profile host-profile--active' : 'host-profile'} type="button" key={profile.id} onClick={() => { void openProfile(profile) }} onContextMenu={(event) => { event.preventDefault(); void openProfileContextMenu(profile, Math.min(event.clientX, window.innerWidth - 176), Math.min(event.clientY, window.innerHeight - 126)) }}><strong>{profile.name}</strong><span>{profile.modifiedTime}</span></button>)}</div><footer><button className="icon-button" type="button" aria-label={t('host.import')} onClick={() => { void importFile() }}><FileInput size={14} /></button><button className="icon-button" type="button" disabled={!content} aria-label={t('host.export')} onClick={() => { void exportFile() }}><Download size={14} /></button><button className="icon-button icon-button--danger" type="button" disabled={!selected} aria-label={t('common.action.delete')} onClick={() => { void remove() }}><Trash2 size={14} /></button></footer></aside>
<main className="host-editor"><div className="host-toolbar"><input className="host-name" value={name} aria-label={t('host.profileName')} placeholder={t('host.profileName')} onChange={(event) => setName(event.target.value)} /><span className="p4-toolbar__spacer" /><button className="toolbar-button" type="button" onClick={() => { void showSystemHosts() }}><Eye size={14} />{t('host.current')}</button><button className="toolbar-button" type="button" onClick={() => { if (findVisible) closeFindReplace(); else openFindReplace() }}><Search size={14} />{t('host.find')}</button><button className="toolbar-button" type="button" disabled={!dirty} onClick={() => { void save() }}><Save size={14} />{t('common.save')}</button><button className="toolbar-button toolbar-button--primary" data-testid="host-apply" type="button" disabled={!content || applying} onClick={() => { void apply() }}><Check size={14} />{applying ? t('host.applying') : t('host.apply')}</button></div>
<main className="host-editor"><div className="host-toolbar"><input className="host-name" value={name} aria-label={t('host.profileName')} placeholder={t('host.profileName')} onChange={(event) => setName(event.target.value)} /><WorkspaceDragZone className="p4-toolbar__spacer" /><button className="toolbar-button" type="button" onClick={() => { void showSystemHosts() }}><Eye size={14} />{t('host.current')}</button><button className="toolbar-button" type="button" onClick={() => { if (findVisible) closeFindReplace(); else openFindReplace() }}><Search size={14} />{t('host.find')}</button><button className="toolbar-button" type="button" disabled={!dirty} onClick={() => { void save() }}><Save size={14} />{t('common.save')}</button><button className="toolbar-button toolbar-button--primary" data-testid="host-apply" type="button" disabled={!content || applying} onClick={() => { void apply() }}><Check size={14} />{applying ? t('host.applying') : t('host.apply')}</button></div>
{findVisible && (
<FindReplaceBar
className="host-findbar"
+2 -2
View File
@@ -1,6 +1,6 @@
import { ClipboardCopy, ClipboardPaste, Download, FileImage, FolderOpen, ImageDown, ImagePlus, List, Maximize2, Minimize2, Minus, Pencil, Plus, Save, ScanLine, Shapes, Trash2, Type, Upload, ZoomIn } from 'lucide-react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { ToolPageHeader } from '@/shared/components/ToolPage'
import { ToolPageHeader, WorkspaceDragZone } 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'
@@ -167,7 +167,7 @@ export function ImageTool() {
<section className="tool-page p4-tool image-tool-page">
<ToolPageHeader title={t('image.title')} />
<div className="local-tool-shell image-workspace">
<div className="image-main-toolbar"><button className="toolbar-button toolbar-button--icon" type="button" aria-label={t('image.toggleList')} onClick={() => setListVisible((value) => !value)}><List size={14} /></button><button className="toolbar-button" type="button" disabled={busy} onClick={() => { void capture() }}><ScanLine size={14} />{t('image.screenshot')}</button><button className="toolbar-button" type="button" onClick={() => { void importClipboard() }}><ClipboardPaste size={14} />{t('image.fromClipboard')}</button><button className="toolbar-button" type="button" onClick={() => { void importImages() }}><FolderOpen size={14} />{t('image.import')}</button><button className="toolbar-button" type="button" onClick={() => setBase64Mode('import')}><ImageDown size={14} />{t('image.fromBase64')}</button><span className="p4-toolbar__spacer" /><button className="toolbar-button" type="button" disabled={!processingNames.length || busy} onClick={() => setSvgOpen(true)}><Shapes size={14} />{t('image.toSvg')}</button><button className="toolbar-button" type="button" disabled={!processingNames.length || busy} onClick={() => setCompressOpen(true)}><Minimize2 size={14} />{t('image.compress')}</button><button className="toolbar-button" type="button" disabled={!processingNames.length || busy} onClick={() => setWatermarkOpen(true)}><Type size={14} />{t('image.watermark')}</button><button className="toolbar-button" type="button" disabled={!current} onClick={() => { void saveCurrent() }}><Save size={14} />{t('common.save')}</button><button className="toolbar-button" type="button" disabled={!current} onClick={() => { void copyImage() }}><ClipboardCopy size={14} />{t('image.copy')}</button><button className="toolbar-button" type="button" disabled={!current} onClick={() => setBase64Mode('export')}><Upload size={14} />{t('image.toBase64')}</button></div>
<div className="image-main-toolbar"><button className="toolbar-button toolbar-button--icon" type="button" aria-label={t('image.toggleList')} onClick={() => setListVisible((value) => !value)}><List size={14} /></button><button className="toolbar-button" type="button" disabled={busy} onClick={() => { void capture() }}><ScanLine size={14} />{t('image.screenshot')}</button><button className="toolbar-button" type="button" onClick={() => { void importClipboard() }}><ClipboardPaste size={14} />{t('image.fromClipboard')}</button><button className="toolbar-button" type="button" onClick={() => { void importImages() }}><FolderOpen size={14} />{t('image.import')}</button><button className="toolbar-button" type="button" onClick={() => setBase64Mode('import')}><ImageDown size={14} />{t('image.fromBase64')}</button><WorkspaceDragZone className="p4-toolbar__spacer" /><button className="toolbar-button" type="button" disabled={!processingNames.length || busy} onClick={() => setSvgOpen(true)}><Shapes size={14} />{t('image.toSvg')}</button><button className="toolbar-button" type="button" disabled={!processingNames.length || busy} onClick={() => setCompressOpen(true)}><Minimize2 size={14} />{t('image.compress')}</button><button className="toolbar-button" type="button" disabled={!processingNames.length || busy} onClick={() => setWatermarkOpen(true)}><Type size={14} />{t('image.watermark')}</button><button className="toolbar-button" type="button" disabled={!current} onClick={() => { void saveCurrent() }}><Save size={14} />{t('common.save')}</button><button className="toolbar-button" type="button" disabled={!current} onClick={() => { void copyImage() }}><ClipboardCopy size={14} />{t('image.copy')}</button><button className="toolbar-button" type="button" disabled={!current} onClick={() => setBase64Mode('export')}><Upload size={14} />{t('image.toBase64')}</button></div>
<ResizableColumns className={listVisible ? 'image-layout' : 'image-layout image-layout--collapsed'} columns={listVisible ? 2 : 1} defaultSizes={listVisible ? [230, 770] : [1]} minPaneWidths={listVisible ? [180, 360] : [360]} storageKey="image-library">
{listVisible && <aside className="image-library"><header><span>{t('image.library')}</span><button className="icon-button" type="button" aria-label={t('image.import')} onClick={() => { void importImages() }}><ImagePlus size={14} /></button></header><div className="image-list">{assets.length === 0 ? <div className="history-empty">{t('image.empty')}</div> : assets.map((asset) => <div className={current?.name === asset.name ? 'image-list-item image-list-item--active' : 'image-list-item'} key={asset.name}><input type="checkbox" aria-label={`${t('image.select')} ${asset.name}`} checked={selectedNames.includes(asset.name)} onChange={(event) => toggleAsset(asset.name, event.target.checked)} /><button type="button" onClick={() => { void selectAsset(asset.name) }}><FileImage size={15} /><span><strong>{asset.name}</strong><small>{asset.width} × {asset.height} · {formatBytes(asset.size)}</small></span></button></div>)}</div><footer><button className="icon-button" type="button" disabled={!current} aria-label={t('common.rename')} onClick={() => { void renameCurrent() }}><Pencil size={14} /></button><button className="icon-button" type="button" disabled={!processingNames.length} aria-label={t('common.export')} onClick={() => { void exportSelected() }}><Download size={14} /></button><button className="icon-button icon-button--danger" type="button" disabled={!processingNames.length} aria-label={t('common.action.delete')} onClick={() => { void deleteSelected() }}><Trash2 size={14} /></button></footer></aside>}
<main className="image-canvas-panel" onDoubleClick={() => { if (current) void window.mootool.openImageAsset(current.name) }}><div className={fit ? 'image-canvas image-canvas--fit' : 'image-canvas'}>{current ? <img src={current.dataUrl} alt={current.name} style={fit ? undefined : { width: `${current.width * zoom}px`, height: `${current.height * zoom}px` }} /> : <div className="image-placeholder"><FileImage size={48} /><span>{t('image.emptyPreview')}</span></div>}</div><div className="image-zoom-toolbar" onDoubleClick={(event) => event.stopPropagation()}><button className="icon-button" type="button" aria-label={t('image.zoomIn')} disabled={!current} onClick={() => { setFit(false); setZoom((value) => Math.min(5, value * 1.1)) }}><Plus size={14} /></button><button className="icon-button" type="button" aria-label={t('image.zoomOut')} disabled={!current} onClick={() => { setFit(false); setZoom((value) => Math.max(0.1, value * 0.9)) }}><Minus size={14} /></button><button className="icon-button" type="button" aria-label={t('image.original')} disabled={!current} onClick={() => { setFit(false); setZoom(1) }}><ZoomIn size={14} /></button><button className="icon-button" type="button" aria-label={t('image.fit')} disabled={!current} onClick={() => setFit(true)}><Maximize2 size={14} /></button><span>{current ? `${current.width} × ${current.height} · ${formatBytes(current.size)} · ${fit ? t('image.fit') : `${Math.round(zoom * 100)}%`}` : ''}</span></div></main>
+2
View File
@@ -2,6 +2,7 @@ import { Copy, Download, Eraser, FolderOpen, History, Minimize2, MoreHorizontal,
import type { ReactNode } from 'react'
import { FindReplaceBar } from '@/shared/components/FindReplaceBar'
import { FontSelect } from '@/shared/components/FontSelect'
import { WorkspaceDragZone } from '@/shared/components/ToolPage'
import { Tooltip } from '@/shared/components/Tooltip'
import type { FindReplaceOptions } from '@/shared/components/findReplace'
import { useI18n } from '@/shared/i18n/I18nProvider'
@@ -84,6 +85,7 @@ export function JsonToolbar({
/>
<IconAction label={wrap ? t('json.action.wrap') : t('json.action.nowrap')} onClick={onToggleWrap}><WrapText size={14} /></IconAction>
<IconAction label={copied ? t('json.action.copied') : t('json.action.copy')} onClick={onCopy}><Copy size={14} /></IconAction>
<WorkspaceDragZone className="workspace-drag-zone--editor-toolbar" />
<span className="toolbar-divider" />
<IconAction label={t('json.action.find')} onClick={onToggleFind}><Search size={14} /></IconAction>
<IconAction label={t('json.action.import')} onClick={onImport}><FolderOpen size={14} /></IconAction>
+2 -2
View File
@@ -1,6 +1,6 @@
import { ClipboardCopy, Globe2, Network, Play, RefreshCw, Search, Square, Trash2 } from 'lucide-react'
import { useEffect, useRef, useState, type KeyboardEvent, type ReactNode } from 'react'
import { ToolPageHeader } from '@/shared/components/ToolPage'
import { ToolPageHeader, WorkspaceDragZone } from '@/shared/components/ToolPage'
import { ResizableColumns } from '@/shared/components/ResizableColumns'
import { TextCodeEditor } from '@/shared/components/TextCodeEditor'
import type { LocalAddressSnapshot, NetworkAction } from '@/shared/contracts/system'
@@ -60,7 +60,7 @@ export function NetTool() {
<section className="tool-page p5-tool net-tool-page">
<ToolPageHeader title={t('net.title')} />
<ResizableColumns className="local-tool-shell net-workspace" columns={2} defaultSizes={[1.15, 0.85]} minPaneWidths={[340, 300]} storageKey="network-workspace">
<section className="net-output-panel"><header><button className="toolbar-button" type="button" disabled={Boolean(running)} onClick={() => { void run('interfaces') }}><Network size={14} />{window.mootool.platform === 'win32' ? 'ipconfig /all' : window.mootool.platform === 'darwin' ? 'ifconfig' : 'ip address'}</button><button className="toolbar-button" type="button" disabled={Boolean(running)} onClick={() => { void run('connections') }}><Globe2 size={14} />netstat</button><span className="p4-toolbar__spacer" />{running && <button className="toolbar-button" type="button" onClick={() => { void stop() }}><Square size={13} />{t('common.stop')}</button>}<button className="icon-button" type="button" aria-label={t('common.action.copy')} disabled={!output} onClick={() => { void actions.copy(output) }}><ClipboardCopy size={14} /></button><button className="icon-button" type="button" aria-label={t('common.action.clear')} disabled={!output} onClick={() => setOutput('')}><Trash2 size={14} /></button></header><pre data-testid="net-output">{output || t('net.outputPlaceholder')}</pre></section>
<section className="net-output-panel"><header><button className="toolbar-button" type="button" disabled={Boolean(running)} onClick={() => { void run('interfaces') }}><Network size={14} />{window.mootool.platform === 'win32' ? 'ipconfig /all' : window.mootool.platform === 'darwin' ? 'ifconfig' : 'ip address'}</button><button className="toolbar-button" type="button" disabled={Boolean(running)} onClick={() => { void run('connections') }}><Globe2 size={14} />netstat</button><WorkspaceDragZone className="p4-toolbar__spacer" />{running && <button className="toolbar-button" type="button" onClick={() => { void stop() }}><Square size={13} />{t('common.stop')}</button>}<button className="icon-button" type="button" aria-label={t('common.action.copy')} disabled={!output} onClick={() => { void actions.copy(output) }}><ClipboardCopy size={14} /></button><button className="icon-button" type="button" aria-label={t('common.action.clear')} disabled={!output} onClick={() => setOutput('')}><Trash2 size={14} /></button></header><pre data-testid="net-output">{output || t('net.outputPlaceholder')}</pre></section>
<section className="net-actions"><NetSection title={t('net.ipv4Long')}><label><span>IPv4</span><input value={ipv4} onChange={(event) => setIpv4(event.target.value)} /></label><div className="net-inline-actions"><button className="dialog-button" type="button" onClick={convertFromIp}>{t('common.convert')} </button><button className="dialog-button" type="button" onClick={convertFromLong}> {t('common.convert')}</button></div><label><span>Long</span><input value={longValue} onChange={(event) => setLongValue(event.target.value)} /></label></NetSection>
<NetSection title={t('net.ping')}><CommandRow value={pingTarget} onChange={setPingTarget} buttonLabel="PING" disabled={Boolean(running)} onRun={() => run('ping', pingTarget)} /></NetSection>
<NetSection title={t('net.ipRangeScan')}>
+1 -1
View File
@@ -102,7 +102,7 @@ export function QrCodeTool() {
return (
<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} />
<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} windowDrag />
{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>}
{tab === 'recognize' && <ResizableColumns className="qrcode-recognize-layout" columns={2} defaultSizes={[1, 1]} minPaneWidths={[300, 280]} storageKey="qrcode-recognize"><section className="qrcode-recognize-source"><div className="p4-toolbar"><button className="dialog-button" type="button" onClick={() => { void chooseRecognitionImage() }}><FolderOpen size={14} />{t('qrcode.chooseImage')}</button><button className="dialog-button" type="button" onClick={() => { void recognizeFromClipboard() }}><ClipboardPaste size={14} />{t('qrcode.fromClipboard')}</button><button className="primary-command" type="button" disabled={!recognitionDataUrl || busy} onClick={() => { void recognize() }}><ScanLine size={14} />{t('qrcode.recognize')}</button></div>{recognitionDataUrl ? <img src={recognitionDataUrl} alt={recognitionName || t('qrcode.sourceImage')} /> : <div className="image-placeholder"><ScanLine size={42} /><span>{t('qrcode.chooseImage')}</span></div>}<span>{recognitionName}</span></section><div className="qrcode-result"><span>{t('qrcode.result')}</span><TextCodeEditor testId="qrcode-result-text" ariaLabel={t('qrcode.result')} value={recognitionResult} readOnly /><button className="toolbar-button" type="button" disabled={!recognitionResult} onClick={() => { void actions.copy(recognitionResult) }}><Copy size={14} />{t('common.action.copy')}</button></div></ResizableColumns>}
{tab === 'history' && <div className="qrcode-history">{history.length === 0 ? <div className="history-empty">{t('history.empty')}</div> : history.map((record) => <article key={record.id}><button type="button" onClick={() => { let meta: { operation?: string } = {}; try { meta = JSON.parse(record.extraData ?? '{}') as { operation?: string } } catch { /* Legacy history. */ } if (meta.operation === 'generate') { setContent(record.inputText); setQrDataUrl(record.outputText); setTab('generate') } else { setRecognitionName(record.inputText); setRecognitionResult(record.outputText); setTab('recognize') } }}><History size={14} /><span><strong>{record.summary}</strong><small>{new Date(record.createTime.replace(' ', 'T')).toLocaleString(language)}</small><p>{record.inputText}</p></span></button><button className="icon-button" type="button" aria-label={t('history.delete')} onClick={() => { void deleteHistory(record.id) }}><Trash2 size={14} /></button></article>)}</div>}
@@ -33,6 +33,7 @@ import { Dialog } from '@/shared/components/Dialog'
import { FindReplaceBar } from '@/shared/components/FindReplaceBar'
import { FontSelect } from '@/shared/components/FontSelect'
import { ResizableColumns } from '@/shared/components/ResizableColumns'
import { WorkspaceDragZone } from '@/shared/components/ToolPage'
import { Tooltip } from '@/shared/components/Tooltip'
import type { CodeEditorViewState } from '@/shared/components/codeEditorViewState'
import { formatCodeEditorContent } from '@/shared/components/codeEditorFormatting'
@@ -929,6 +930,7 @@ export function QuickNoteTool() {
<section className="tool-page quick-note-tool">
<div className="tool-page__header tool-page__header--actions quick-note-page-header">
<h1 className="visually-hidden">{t('quickNote.title')}</h1>
<WorkspaceDragZone />
<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 })}>
@@ -5,6 +5,7 @@ import { useSettings } from '@/features/settings/SettingsProvider'
import { Dialog } from '@/shared/components/Dialog'
import { ResizableColumns } from '@/shared/components/ResizableColumns'
import { TextCodeEditor } from '@/shared/components/TextCodeEditor'
import { WorkspaceDragZone } from '@/shared/components/ToolPage'
import { Tooltip } from '@/shared/components/Tooltip'
import type { RuntimeStatus } from '@/shared/contracts/app'
import type { CodeRuntimeId, RuntimeExecutionResult } from '@/shared/contracts/runtime'
@@ -212,6 +213,7 @@ export function RuntimeTool() {
<section className="tool-page runtime-tool">
<div className="tool-page__header tool-page__header--actions">
<h1 className="visually-hidden">{t('runtime.title')}</h1>
<WorkspaceDragZone />
<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}
@@ -3,6 +3,7 @@ import { useEffect, useMemo, useReducer } from 'react'
import { createPortal } from 'react-dom'
import { useToolActivity } from '@/shared/components/ToolActivity'
import { HistoryDialog } from '@/features/history/HistoryDialog'
import { WorkspaceDragZone } from '@/shared/components/ToolPage'
import { Tooltip } from '@/shared/components/Tooltip'
import { useToast } from '@/shared/feedback/ToastProvider'
import { useI18n } from '@/shared/i18n/I18nProvider'
@@ -100,6 +101,7 @@ export function TimeConvertTool() {
<section className="tool-page time-tool">
<div className="tool-page__header tool-page__header--actions">
<h1 className="visually-hidden">{t('time.title')}</h1>
<WorkspaceDragZone />
<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>
@@ -1,6 +1,6 @@
import { ArrowLeftRight, Copy, History, Languages, Plus, Save, Search, Star, Trash2, X } from 'lucide-react'
import { useCallback, useEffect, useRef, useState } from 'react'
import { ToolPageHeader, ToolTabs } from '@/shared/components/ToolPage'
import { ToolPageHeader, ToolTabs, WorkspaceDragZone } from '@/shared/components/ToolPage'
import { translationLanguageCodes, type TranslationHistory, type TranslationProvider, type TranslationWord } from '@/shared/contracts/network'
import { useSettings } from '@/features/settings/SettingsProvider'
import { ResizableColumns } from '@/shared/components/ResizableColumns'
@@ -154,9 +154,9 @@ export function TranslationTool() {
<section className="tool-page p5-tool translation-tool-page">
<ToolPageHeader title={t('translation.title')} />
<div className="local-tool-shell translation-workspace">
<ToolTabs tabs={(['translate', 'words', 'history'] as TranslationTab[]).map((id) => ({ id, label: t(`translation.tab.${id}` as 'translation.tab.translate') }))} active={tab} onChange={setTab} />
<ToolTabs tabs={(['translate', 'words', 'history'] as TranslationTab[]).map((id) => ({ id, label: t(`translation.tab.${id}` as 'translation.tab.translate') }))} active={tab} onChange={setTab} windowDrag />
{tab === 'translate' && <div className="translation-main">
<div className="translation-toolbar"><LanguageSelect value={settings.tools.translationSourceLang} includeAuto onChange={changeSourceLanguage} /><button className="icon-button" type="button" aria-label={t('translation.exchange')} onClick={exchange}><ArrowLeftRight size={14} /></button><LanguageSelect value={settings.tools.translationTargetLang} onChange={changeTargetLanguage} /><span className="p4-toolbar__spacer" /><label>{t('translation.provider')}<select value={settings.tools.translationProvider} onChange={(event) => changeProvider(event.target.value as TranslationProvider)}><option value="google">Google</option><option value="bing">Bing</option></select></label><button className="icon-button" type="button" aria-label={t('translation.copy')} disabled={!target} onClick={() => { void actions.copy(target) }}><Copy size={14} /></button><button className="icon-button" type="button" aria-label={t('translation.saveWord')} disabled={!source} onClick={() => { void saveWord() }}><Star size={14} /></button><button className="icon-button" type="button" aria-label={t('common.action.clear')} onClick={clear}><X size={14} /></button></div>
<div className="translation-toolbar"><LanguageSelect value={settings.tools.translationSourceLang} includeAuto onChange={changeSourceLanguage} /><button className="icon-button" type="button" aria-label={t('translation.exchange')} onClick={exchange}><ArrowLeftRight size={14} /></button><LanguageSelect value={settings.tools.translationTargetLang} onChange={changeTargetLanguage} /><WorkspaceDragZone className="p4-toolbar__spacer" /><label>{t('translation.provider')}<select value={settings.tools.translationProvider} onChange={(event) => changeProvider(event.target.value as TranslationProvider)}><option value="google">Google</option><option value="bing">Bing</option></select></label><button className="icon-button" type="button" aria-label={t('translation.copy')} disabled={!target} onClick={() => { void actions.copy(target) }}><Copy size={14} /></button><button className="icon-button" type="button" aria-label={t('translation.saveWord')} disabled={!source} onClick={() => { void saveWord() }}><Star size={14} /></button><button className="icon-button" type="button" aria-label={t('common.action.clear')} onClick={clear}><X size={14} /></button></div>
<ResizableColumns className="translation-editor-grid" columns={2} defaultSizes={[1, 1]} minPaneWidths={[280, 280]} storageKey="translation-editor"><TextCodeEditor className="translation-source-editor" testId="translation-source" ariaLabel={t('translation.sourcePlaceholder')} value={source} placeholder={t('translation.sourcePlaceholder')} onChange={changeSource} /><div className="translation-result"><TextCodeEditor className="translation-target-editor" testId="translation-result" ariaLabel={t('translation.targetPlaceholder')} value={translating ? t('translation.translating') : target} readOnly /><footer>{providerUsed && <span><Languages size={13} />{providerUsed === 'google' ? 'Google' : 'Bing'}{fallbackUsed ? ` · ${t('translation.fallback')}` : ''}</span>}<span>{source.length} / 50000</span></footer></div></ResizableColumns>
</div>}
{tab === 'words' && <WordBook onApply={(word) => { restore(word.sourceText, word.targetText); void updateSettings({ tools: { translationSourceLang: word.sourceLang, translationTargetLang: word.targetLang } }); setTab('translate') }} onRetranslate={(word) => translateWord(word, settings, actions.reportError)} />}
+1 -1
View File
@@ -51,7 +51,7 @@ export function ToolWindow({ requestedToolId }: { requestedToolId: string }) {
].filter(Boolean).join(' ')
return (
<main className={shellClassName}>
<div className="window-drag window-drag-region" aria-hidden="true" />
{detached && <div className="window-drag window-drag-rail" data-window-drag-rail aria-hidden="true" />}
{detached && !immersive && (
<div
className={windowControlsVisible ? 'tool-window-brand-zone tool-window-brand-zone--controls-visible' : 'tool-window-brand-zone'}
+14 -1
View File
@@ -4,19 +4,31 @@ import { TextCodeEditor } from './TextCodeEditor'
import { Tooltip } from './Tooltip'
import { useI18n } from '@/shared/i18n/I18nProvider'
export function WorkspaceDragZone({ className = '' }: { className?: string }) {
return (
<div
className={['workspace-drag-zone', className].filter(Boolean).join(' ')}
data-window-drag-zone
aria-hidden="true"
/>
)
}
export function ToolPageHeader({ title, actions }: { title: string; actions?: ReactNode }) {
return (
<div className={actions ? 'tool-page__header tool-page__header--actions' : 'tool-page__header tool-page__header--semantic'}>
<h1 className="visually-hidden">{title}</h1>
{actions && <WorkspaceDragZone />}
{actions && <div className="tool-header-actions">{actions}</div>}
</div>
)
}
export function ToolTabs<T extends string>({ tabs, active, onChange }: {
export function ToolTabs<T extends string>({ tabs, active, onChange, windowDrag = false }: {
tabs: ReadonlyArray<{ id: T; label: string }>
active: T
onChange: (id: T) => void
windowDrag?: boolean
}) {
return (
<div className="tool-tabs" role="tablist">
@@ -32,6 +44,7 @@ export function ToolTabs<T extends string>({ tabs, active, onChange }: {
{tab.label}
</button>
))}
{windowDrag && <WorkspaceDragZone className="workspace-drag-zone--tabs" />}
</div>
)
}
+44 -7
View File
@@ -1919,6 +1919,25 @@ select:not([multiple]) {
-webkit-app-region: drag;
}
.workspace-drag-zone {
min-width: 48px;
min-height: 24px;
align-self: stretch;
flex: 1 1 80px;
user-select: none;
-webkit-user-select: none;
-webkit-app-region: drag;
}
.workspace-drag-zone--tabs {
min-width: 64px;
}
.workspace-drag-zone--editor-toolbar {
min-width: 32px;
flex-basis: 48px;
}
.app-shell {
--navigation-width: 248px;
position: relative;
@@ -2514,6 +2533,21 @@ select:not([multiple]) {
overflow: hidden;
}
.tool-view-shell--detached.tool-view-shell--immersive {
padding-top: 12px;
}
.window-drag-rail {
position: absolute;
z-index: 220;
top: 0;
right: 0;
left: 0;
height: 12px;
user-select: none;
-webkit-user-select: none;
}
.tool-view-shell .tool-page__header {
padding-right: 64px;
}
@@ -11805,12 +11839,9 @@ textarea:focus {
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;
}
/* Immersive tools keep window mechanics in real toolbar gaps. Detached tools
* additionally reserve a narrow rail above the workspace as a reliable
* fallback, so the rail never covers an input or button. */
.tool-view-shell--immersive .tool-window-toggle-slot {
top: 50%;
@@ -11904,7 +11935,13 @@ textarea:focus {
}
:root .tool-page__header--actions > :not(.visually-hidden) {
margin-left: auto;
margin-left: 0;
}
:root .tool-page__header--actions > .workspace-drag-zone {
position: relative;
z-index: 101;
-webkit-app-region: drag;
}
:root .tool-page > .local-tool-shell,
+12 -2
View File
@@ -51,6 +51,14 @@ test('renders every functional tool as an immersive workspace while leaving home
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
const workspaceDragUsable = [...element.querySelectorAll<HTMLElement>('[data-window-drag-zone]')].some((zone) => {
const rect = zone.getBoundingClientRect()
return rect.width >= 32
&& rect.height >= 24
&& rect.right > bounds.left
&& rect.left < bounds.right
&& getComputedStyle(zone).getPropertyValue('-webkit-app-region') === 'drag'
})
return {
topAligned: workspace ? Math.abs(bounds.top - workspace.top) < 0.5 : false,
padding: [style.paddingTop, style.paddingRight, style.paddingBottom, style.paddingLeft],
@@ -60,7 +68,8 @@ test('renders every functional tool as an immersive workspace while leaving home
const rect = control.getBoundingClientRect()
return rect.width > 0 && rect.height > 0
}),
outerSurfaceFlat: !outerStyle || (outerStyle.borderRadius === '0px' && outerStyle.boxShadow === 'none')
outerSurfaceFlat: !outerStyle || (outerStyle.borderRadius === '0px' && outerStyle.boxShadow === 'none'),
workspaceDragUsable
}
})).toEqual({
topAligned: true,
@@ -68,7 +77,8 @@ test('renders every functional tool as an immersive workspace while leaving home
visiblePageTitle: false,
actionHeaderFlush: true,
actionControlsVisible: true,
outerSurfaceFlat: true
outerSurfaceFlat: true,
workspaceDragUsable: label !== 'HTTP 请求'
})
}
+31 -12
View File
@@ -48,10 +48,14 @@ test('moves one live tool view into a separate window and restores it without lo
})).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 dragZoneElement = document.querySelector('[data-window-drag-zone]')
const dragZone = dragZoneElement?.getBoundingClientRect()
const dragRail = document.querySelector('[data-window-drag-rail]')
const slot = document.querySelector('.tool-window-toggle-slot').getBoundingClientRect()
return shell?.classList.contains('tool-view-shell--immersive')
&& Math.round(dragRegion.height) === 6
&& !dragRail
&& Boolean(dragZone && dragZone.width >= 48 && dragZone.height >= 24)
&& getComputedStyle(dragZoneElement).getPropertyValue('-webkit-app-region') === 'drag'
&& Math.round(slot.width) === 7
})()`)).value).toBe(true)
@@ -115,12 +119,14 @@ test('keeps multiple detached tools independent and returns each one to its dock
await expect.poll(async () => (await evaluateTool('json', `(() => {
const shell = document.querySelector('.tool-view-shell')
const page = document.querySelector('.json-tool')
const drag = document.querySelector('.window-drag-region')
const drag = document.querySelector('[data-window-drag-rail]')
const workspaceDrag = document.querySelector('[data-window-drag-zone]')
const slot = document.querySelector('.tool-window-toggle-slot')
const trigger = slot?.querySelector('.tooltip-trigger')
if (!shell || !page || !drag || !slot || !trigger) return null
if (!shell || !page || !drag || !workspaceDrag || !slot || !trigger) return null
const pageBounds = page.getBoundingClientRect()
const dragBounds = drag.getBoundingClientRect()
const workspaceDragBounds = workspaceDrag.getBoundingClientRect()
const slotBounds = slot.getBoundingClientRect()
const triggerStyle = getComputedStyle(trigger)
return {
@@ -130,6 +136,9 @@ test('keeps multiple detached tools independent and returns each one to its dock
brandCount: shell.querySelectorAll('.tool-window-brand-zone').length,
pageTop: Math.round(pageBounds.top),
dragHeight: Math.round(dragBounds.height),
workspaceDragUsable: workspaceDragBounds.width >= 32 && workspaceDragBounds.height >= 24,
dragRailRegion: getComputedStyle(drag).getPropertyValue('-webkit-app-region'),
workspaceDragRegion: getComputedStyle(workspaceDrag).getPropertyValue('-webkit-app-region'),
edgeSlotWidth: Math.round(slotBounds.width),
toggleOpacity: triggerStyle.opacity,
togglePointerEvents: triggerStyle.pointerEvents
@@ -139,8 +148,11 @@ test('keeps multiple detached tools independent and returns each one to its dock
accessibleName: 'JSON 工作台',
visibleTitleCount: 0,
brandCount: 0,
pageTop: 0,
dragHeight: 6,
pageTop: 12,
dragHeight: 12,
workspaceDragUsable: true,
dragRailRegion: 'drag',
workspaceDragRegion: 'drag',
edgeSlotWidth: 7,
toggleOpacity: '0',
togglePointerEvents: 'none'
@@ -187,9 +199,10 @@ test('keeps a custom-header tool immersive while preserving its view controls',
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 drag = document.querySelector('[data-window-drag-rail]')?.getBoundingClientRect()
const workspaceDrag = document.querySelector('[data-window-drag-zone]')?.getBoundingClientRect()
const slot = document.querySelector('.tool-window-toggle-slot')?.getBoundingClientRect()
if (!shell || !page || !header || !title || !switcher || !drag || !slot) return null
if (!shell || !page || !header || !title || !switcher || !drag || !workspaceDrag || !slot) return null
return {
immersive: shell.classList.contains('tool-view-shell--immersive'),
brandCount: shell.querySelectorAll('.tool-window-brand-zone').length,
@@ -200,18 +213,24 @@ test('keeps a custom-header tool immersive while preserving its view controls',
titleHeight: Math.round(title.height),
switchVisible: switcher.width > 0 && switcher.height > 0,
dragHeight: Math.round(drag.height),
workspaceDragUsable: workspaceDrag.width >= 48 && workspaceDrag.height >= 24,
dragRailRegion: getComputedStyle(document.querySelector('[data-window-drag-rail]')).getPropertyValue('-webkit-app-region'),
workspaceDragRegion: getComputedStyle(document.querySelector('[data-window-drag-zone]')).getPropertyValue('-webkit-app-region'),
edgeSlotWidth: Math.round(slot.width)
}
})()`)).value).toMatchObject({
immersive: true,
brandCount: 0,
pageTop: 0,
headerTop: 0,
pageTop: 12,
headerTop: 12,
headerCompact: true,
titleWidth: 1,
titleHeight: 1,
switchVisible: true,
dragHeight: 6,
dragHeight: 12,
workspaceDragUsable: true,
dragRailRegion: 'drag',
workspaceDragRegion: 'drag',
edgeSlotWidth: 7
})
@@ -225,7 +244,7 @@ test('keeps a custom-header tool immersive while preserving its view controls',
})()`)).value).toEqual({
controlsClass: true,
brandCount: 0,
headerTop: 0
headerTop: 12
})
await sendToolWindowControlsVisibility('quickNote', false)