502 lines
12 KiB
Vue
502 lines
12 KiB
Vue
<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>
|