Files
Thing/src/modules/screenshot/ScreenshotOverlay.vue
T
2026-07-31 18:31:13 +08:00

502 lines
12 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<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>