截图模块初始化

This commit is contained in:
zhongluofeng
2026-07-31 18:31:13 +08:00
parent 66575c6166
commit 89e5b7bed5
7 changed files with 2082 additions and 0 deletions
@@ -0,0 +1,501 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'
import { invoke } from '@tauri-apps/api/core'
import { getCurrentWindow } from '@tauri-apps/api/window'
import { WebviewWindow } from '@tauri-apps/api/webviewWindow'
interface CaptureData {
pngBase64: string
width: number
height: number
}
interface WindowInfo {
hwnd: number
title: string
rect: { x: number; y: number; width: number; height: number }
}
const win = getCurrentWindow()
const hash = window.location.hash
const query = hash.split('?')[1] || ''
const params = new URLSearchParams(query)
const mode = (params.get('mode') || 'region') as 'region' | 'window'
const imgEl = ref<HTMLImageElement | null>(null)
const imgSrc = ref('')
const imgWidth = ref(0) // 自然(物理)像素
const imgHeight = ref(0)
const loading = ref(true)
const errorMsg = ref('')
// 选区状态(CSS 逻辑像素,相对窗口左上角)
const dragging = ref(false)
const startX = ref(0)
const startY = ref(0)
const curX = ref(0)
const curY = ref(0)
const hasSelection = ref(false)
// 窗口拾取高亮(CSS 逻辑像素)
const winHighlight = ref<{
x: number
y: number
w: number
h: number
title: string
} | null>(null)
// 物理坐标换算所需
let winOuterX = 0
let winOuterY = 0
let dpr = 1
let currentHwnd = 0
let pickRaf = 0
let lastPickX = -1
let lastPickY = -1
const sel = computed(() => {
const x = Math.min(startX.value, curX.value)
const y = Math.min(startY.value, curY.value)
const w = Math.abs(curX.value - startX.value)
const h = Math.abs(curY.value - startY.value)
return { x, y, w, h }
})
const selSize = computed(() => {
if (!imgWidth.value || !imgHeight.value) return null
const sx = imgWidth.value / window.innerWidth
const sy = imgHeight.value / window.innerHeight
return { w: Math.round(sel.value.w * sx), h: Math.round(sel.value.h * sy) }
})
// 工具栏位置(选区右下方,溢出时翻到上方/左方)
const toolbarPos = computed(() => {
if (!hasSelection.value) return null
const s = sel.value
let left = s.x + s.w + 8
let top = s.y + s.h + 8
if (left + 280 > window.innerWidth) left = s.x + s.w - 280
if (top + 40 > window.innerHeight) top = s.y - 48
return { left: Math.max(8, left), top: Math.max(8, top) }
})
onMounted(async () => {
try {
const pos = await win.outerPosition()
winOuterX = pos.x
winOuterY = pos.y
dpr = window.devicePixelRatio || 1
const data = await invoke<CaptureData | null>('screenshot_take_fullscreen')
if (!data) {
errorMsg.value = '未找到屏幕捕获数据'
loading.value = false
return
}
imgSrc.value = 'data:image/png;base64,' + data.pngBase64
imgWidth.value = data.width
imgHeight.value = data.height
loading.value = false
await win.show()
await win.setFocus()
} catch (e) {
errorMsg.value = (e as Error).message
loading.value = false
}
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
window.addEventListener('keydown', onKeyDown)
})
onUnmounted(() => {
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
window.removeEventListener('keydown', onKeyDown)
})
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return
if (mode === 'window') {
if (currentHwnd) finishWindowCapture(currentHwnd)
return
}
// 区域模式:开始拖拽,清除上次选区
dragging.value = true
hasSelection.value = false
startX.value = e.clientX
startY.value = e.clientY
curX.value = e.clientX
curY.value = e.clientY
}
function onMouseMove(e: MouseEvent) {
if (mode === 'window') {
scheduleWindowPick(e.clientX, e.clientY)
return
}
if (dragging.value) {
curX.value = e.clientX
curY.value = e.clientY
}
}
function onMouseUp() {
if (mode !== 'region' || !dragging.value) return
dragging.value = false
if (sel.value.w < 4 || sel.value.h < 4) {
hasSelection.value = false
return
}
hasSelection.value = true
}
function onKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
cancel()
} else if (e.key === 'Enter' && mode === 'region' && hasSelection.value) {
void doEdit()
}
}
function scheduleWindowPick(cssX: number, cssY: number) {
const physX = Math.round(winOuterX + cssX * dpr)
const physY = Math.round(winOuterY + cssY * dpr)
if (physX === lastPickX && physY === lastPickY) return
lastPickX = physX
lastPickY = physY
if (pickRaf) return
pickRaf = requestAnimationFrame(async () => {
pickRaf = 0
try {
const info = await invoke<WindowInfo | null>('screenshot_window_from_point', {
x: physX,
y: physY,
})
if (info) {
winHighlight.value = {
x: (info.rect.x - winOuterX) / dpr,
y: (info.rect.y - winOuterY) / dpr,
w: info.rect.width / dpr,
h: info.rect.height / dpr,
title: info.title,
}
currentHwnd = info.hwnd
} else {
winHighlight.value = null
currentHwnd = 0
}
} catch {
// 忽略拾取错误
}
})
}
/** 区域裁剪:从底图自然像素裁剪,返回 base64(无 data: 前缀) */
function cropSelection(): string | null {
const img = imgEl.value
if (!img || !imgWidth.value) return null
const scaleX = imgWidth.value / window.innerWidth
const scaleY = imgHeight.value / window.innerHeight
const sx = Math.round(sel.value.x * scaleX)
const sy = Math.round(sel.value.y * scaleY)
const sw = Math.round(sel.value.w * scaleX)
const sh = Math.round(sel.value.h * scaleY)
if (sw < 2 || sh < 2) return null
const canvas = document.createElement('canvas')
canvas.width = sw
canvas.height = sh
const ctx = canvas.getContext('2d')
if (!ctx) return null
ctx.drawImage(img, sx, sy, sw, sh, 0, 0, sw, sh)
const url = canvas.toDataURL('image/png')
return url.slice(url.indexOf(',') + 1)
}
async function doEdit() {
const base64 = cropSelection()
if (!base64) return
await invoke('screenshot_set_editor_image', { pngBase64: base64 })
await openEditorAndClose()
}
async function doCopy() {
const base64 = cropSelection()
if (!base64) return
try {
await invoke('screenshot_copy_image', { pngBase64: base64 })
} catch (e) {
console.error(e)
}
await win.close()
}
async function doSave() {
const base64 = cropSelection()
if (!base64) return
try {
const { save } = await import('@tauri-apps/plugin-dialog')
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19)
const path = await save({
defaultPath: `screenshot_${ts}.png`,
filters: [{ name: 'PNG', extensions: ['png'] }],
})
if (path) await invoke('screenshot_save_png', { pngBase64: base64, path })
} catch (e) {
console.error(e)
}
await win.close()
}
async function finishWindowCapture(hwnd: number) {
if (!hwnd) return
try {
const data = await invoke<CaptureData>('screenshot_capture_window', { hwnd })
await invoke('screenshot_set_editor_image', { pngBase64: data.pngBase64 })
await openEditorAndClose()
} catch (e) {
console.error('[screenshot] 窗口捕获失败', e)
// 失败则留在覆盖层,用户可重试或取消
}
}
async function openEditorAndClose() {
const label = 'screenshot-editor'
try {
const existing = await WebviewWindow.getByLabel(label)
if (!existing) {
new WebviewWindow(label, {
url: 'index.html#screenshot-editor',
title: '截图编辑器',
width: 960,
height: 720,
minWidth: 640,
minHeight: 480,
decorations: false,
transparent: true,
resizable: true,
shadow: true,
focus: true,
visible: true,
})
} else {
await existing.show()
await existing.setFocus()
}
} catch (e) {
console.error(e)
}
await win.close()
}
function cancel() {
void win.close()
}
</script>
<template>
<div
class="overlay-root"
:class="mode === 'window' ? 'cursor-crosshair' : 'cursor-crosshair'"
@mousedown="onMouseDown"
>
<!-- 冻结的屏幕底图 -->
<img
v-if="imgSrc"
ref="imgEl"
:src="imgSrc"
class="bg-img"
draggable="false"
@load="() => {}"
/>
<!-- 加载中 -->
<div v-if="loading" class="hint">正在准备截图</div>
<div v-else-if="errorMsg" class="hint error">
{{ errorMsg }}
<button class="btn" @click="cancel">关闭</button>
</div>
<!-- 区域模式选区遮罩 + 选框 -->
<template v-if="mode === 'region' && !loading && !errorMsg">
<div
v-if="dragging || hasSelection"
class="selection"
:style="{
left: sel.x + 'px',
top: sel.y + 'px',
width: sel.w + 'px',
height: sel.h + 'px',
}"
>
<span v-if="selSize && (dragging || hasSelection)" class="size-tag">
{{ selSize.w }} × {{ selSize.h }}
</span>
</div>
<!-- 选区工具栏 -->
<div
v-if="hasSelection && toolbarPos"
class="toolbar"
:style="{ left: toolbarPos.left + 'px', top: toolbarPos.top + 'px' }"
@mousedown.stop
>
<button class="tb-btn primary" title="编辑" @click="doEdit">编辑</button>
<button class="tb-btn" title="复制到剪贴板" @click="doCopy">复制</button>
<button class="tb-btn" title="保存到文件" @click="doSave">保存</button>
<button class="tb-btn icon" title="取消 (Esc)" @click="cancel"></button>
</div>
</template>
<!-- 窗口模式高亮框 -->
<template v-if="mode === 'window' && !loading && !errorMsg && winHighlight">
<div
class="win-highlight"
:style="{
left: winHighlight.x + 'px',
top: winHighlight.y + 'px',
width: winHighlight.w + 'px',
height: winHighlight.h + 'px',
}"
>
<span class="win-title">{{ winHighlight.title }}</span>
</div>
</template>
<!-- 底部提示 -->
<div v-if="!loading && !errorMsg" class="bottom-hint">
<template v-if="mode === 'region'">
拖动选择区域 · Enter 编辑 · Esc 取消
</template>
<template v-else>
点击窗口捕获 · Esc 取消
</template>
</div>
</div>
</template>
<style scoped>
.overlay-root {
position: fixed;
inset: 0;
overflow: hidden;
user-select: none;
}
.bg-img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: fill;
pointer-events: none;
}
.cursor-crosshair {
cursor: crosshair;
}
.hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
gap: 12px;
color: #fff;
font-size: 14px;
background: rgba(0, 0, 0, 0.45);
}
.hint.error {
flex-direction: column;
}
/* 选区:透明内部 + 巨大 box-shadow 形成外部遮罩 */
.selection {
position: absolute;
border: 1px solid #3b82f6;
box-shadow: 0 0 0 9999px rgba(0, 0, 0, 0.45);
pointer-events: none;
}
.size-tag {
position: absolute;
top: -24px;
left: 0;
padding: 2px 6px;
background: #3b82f6;
color: #fff;
font-size: 12px;
border-radius: 3px;
white-space: nowrap;
}
.toolbar {
position: absolute;
display: flex;
gap: 4px;
padding: 4px;
background: rgba(24, 24, 27, 0.95);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 6px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 10;
}
.tb-btn {
padding: 4px 10px;
background: transparent;
border: none;
color: #e4e4e7;
font-size: 13px;
border-radius: 4px;
cursor: pointer;
}
.tb-btn:hover {
background: rgba(255, 255, 255, 0.1);
}
.tb-btn.primary {
background: #3b82f6;
color: #fff;
}
.tb-btn.primary:hover {
background: #2563eb;
}
.tb-btn.icon {
padding: 4px 8px;
}
.win-highlight {
position: absolute;
border: 2px solid #3b82f6;
background: rgba(59, 130, 246, 0.12);
pointer-events: none;
}
.win-title {
position: absolute;
top: -22px;
left: -2px;
max-width: 240px;
padding: 1px 6px;
background: #3b82f6;
color: #fff;
font-size: 12px;
border-radius: 3px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.bottom-hint {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
padding: 4px 12px;
background: rgba(24, 24, 27, 0.8);
color: #d4d4d8;
font-size: 12px;
border-radius: 4px;
pointer-events: none;
}
.btn {
padding: 4px 12px;
background: #3b82f6;
border: none;
color: #fff;
border-radius: 4px;
cursor: pointer;
}
</style>