Files
SnapGo/frontend/src/views/CaptureOverlay.vue
T
mamamiyear 050f2b74c3 feat: save short cut image to remote by ssh
- add settings for ssh config
- implement save remote function by ssh
2026-06-11 21:14:29 +08:00

852 lines
20 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 { computed, onMounted, onUnmounted, ref } from 'vue'
// Inline SVG markup imported as raw strings via Vite's `?raw` suffix.
// Rationale: rendering through v-html lets the icon inherit `currentColor`
// from the toolbar button, so styling stays in CSS without bundling extra
// SVG-as-component plugins.
import cancelIcon from '../assets/icons/cancel.svg?raw'
import copyIcon from '../assets/icons/copy.svg?raw'
import saveIcon from '../assets/icons/save-local.svg?raw'
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
import uploadIcon from '../assets/icons/upload.svg?raw'
interface Props {
width: number
height: number
}
const props = defineProps<Props>()
interface Rect {
x: number
y: number
w: number
h: number
}
type Tool = 'pen' | 'rect' | 'ellipse'
interface Point {
x: number
y: number
}
interface Annotation {
tool: Tool
color: string
points: Point[]
}
const emit = defineEmits<{
(
e: 'confirm',
payload: { rect: Rect; annotations: Annotation[] }
): void
(
e: 'copy',
payload: { rect: Rect; annotations: Annotation[] }
): void
(
e: 'save',
payload: { rect: Rect; annotations: Annotation[] }
): void
(
e: 'save-remote',
payload: { rect: Rect; annotations: Annotation[] }
): void
(e: 'cancel'): void
}>()
const rect = ref<Rect | null>(null)
const annotations = ref<Annotation[]>([])
const draftAnnotation = ref<Annotation | null>(null)
const activeTool = ref<Tool>('pen')
const activeColor = ref('#ef4444')
const paletteOpen = ref(false)
type ResizeHandle = 'n' | 's' | 'e' | 'w' | 'nw' | 'ne' | 'sw' | 'se'
type DragMode = 'idle' | 'creating' | 'moving' | 'resizing' | 'annotating'
const dragMode = ref<DragMode>('idle')
const resizeHandle = ref<ResizeHandle | null>(null)
const dragAnchor = ref({ x: 0, y: 0 })
const startRect = ref<Rect | null>(null)
const colors = [
'#ef4444',
'#f97316',
'#facc15',
'#22c55e',
'#06b6d4',
'#3b82f6',
'#8b5cf6',
'#ec4899',
'#ffffff',
'#111827',
]
const maskPath = computed(() => {
const outer = `M0 0 H${props.width} V${props.height} H0 Z`
if (!rect.value) return outer
const r = rect.value
const inner = `M${r.x} ${r.y} H${r.x + r.w} V${r.y + r.h} H${r.x} Z`
return outer + ' ' + inner
})
const allAnnotations = computed(() => {
return draftAnnotation.value
? [...annotations.value, draftAnnotation.value]
: annotations.value
})
const selectionAnnotations = computed(() => {
if (!rect.value) return []
return allAnnotations.value.map((annotation) => ({
...annotation,
points: annotation.points.map((point) => ({
x: rect.value!.x + point.x,
y: rect.value!.y + point.y,
})),
}))
})
const sizeLabel = computed(() => {
if (!rect.value) return ''
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
})
const rightToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, 180, 40, 'right')
})
const leftToolbarPos = computed(() => {
if (!rect.value) return null
return placeToolbar(rect.value, 190, 40, 'left')
})
const handles = computed(() => {
if (!rect.value) return []
const r = rect.value
const cx = r.x + r.w / 2
const cy = r.y + r.h / 2
return [
{ name: 'nw', x: r.x, y: r.y },
{ name: 'n', x: cx, y: r.y },
{ name: 'ne', x: r.x + r.w, y: r.y },
{ name: 'e', x: r.x + r.w, y: cy },
{ name: 'se', x: r.x + r.w, y: r.y + r.h },
{ name: 's', x: cx, y: r.y + r.h },
{ name: 'sw', x: r.x, y: r.y + r.h },
{ name: 'w', x: r.x, y: cy },
] as Array<{ name: ResizeHandle; x: number; y: number }>
})
function placeToolbar(
r: Rect,
toolbarW: number,
toolbarH: number,
side: 'left' | 'right'
) {
const gap = 8
let x =
side === 'right'
? r.x + r.w - toolbarW
: r.x
let y = r.y + r.h + gap
if (y + toolbarH > props.height) {
y = r.y + r.h - toolbarH - gap
}
x = clamp(x, 0, props.width - toolbarW)
return { x, y }
}
function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(value, max))
}
function normalizeRect(a: Point, b: Point): Rect {
return {
x: clamp(Math.min(a.x, b.x), 0, props.width),
y: clamp(Math.min(a.y, b.y), 0, props.height),
w: Math.abs(b.x - a.x),
h: Math.abs(b.y - a.y),
}
}
function pointFromEvent(e: MouseEvent): Point {
return {
x: clamp(e.clientX, 0, props.width),
y: clamp(e.clientY, 0, props.height),
}
}
function localPoint(p: Point) {
if (!rect.value) return p
return {
x: clamp(p.x - rect.value.x, 0, rect.value.w),
y: clamp(p.y - rect.value.y, 0, rect.value.h),
}
}
function insideRect(p: Point, r: Rect | null) {
if (!r) return false
return p.x >= r.x && p.x <= r.x + r.w && p.y >= r.y && p.y <= r.y + r.h
}
function hitHandle(p: Point): ResizeHandle | null {
if (!rect.value) return null
const tolerance = 9
for (const handle of handles.value) {
if (
Math.abs(p.x - handle.x) <= tolerance &&
Math.abs(p.y - handle.y) <= tolerance
) {
return handle.name
}
}
const r = rect.value
const nearX = p.x >= r.x - tolerance && p.x <= r.x + r.w + tolerance
const nearY = p.y >= r.y - tolerance && p.y <= r.y + r.h + tolerance
if (nearX && Math.abs(p.y - r.y) <= tolerance) return 'n'
if (nearX && Math.abs(p.y - (r.y + r.h)) <= tolerance) return 's'
if (nearY && Math.abs(p.x - r.x) <= tolerance) return 'w'
if (nearY && Math.abs(p.x - (r.x + r.w)) <= tolerance) return 'e'
return null
}
function onMouseDown(e: MouseEvent) {
if (e.button !== 0) return
paletteOpen.value = false
const p = pointFromEvent(e)
const handle = hitHandle(p)
if (handle && rect.value) {
dragMode.value = 'resizing'
resizeHandle.value = handle
dragAnchor.value = p
startRect.value = { ...rect.value }
return
}
if (rect.value && insideRect(p, rect.value)) {
dragMode.value = 'moving'
dragAnchor.value = { x: p.x - rect.value.x, y: p.y - rect.value.y }
return
}
dragMode.value = 'creating'
dragAnchor.value = p
rect.value = { x: p.x, y: p.y, w: 0, h: 0 }
annotations.value = []
draftAnnotation.value = null
}
function onSelectionMouseDown(e: MouseEvent) {
if (e.button !== 0 || !rect.value) return
e.stopPropagation()
paletteOpen.value = false
if (e.detail >= 2) {
removeSinglePointAnnotation()
onCopy()
return
}
const p = pointFromEvent(e)
const handle = hitHandle(p)
if (handle) {
dragMode.value = 'resizing'
resizeHandle.value = handle
dragAnchor.value = p
startRect.value = { ...rect.value }
return
}
dragMode.value = 'annotating'
const local = localPoint(p)
draftAnnotation.value = {
tool: activeTool.value,
color: activeColor.value,
points: [local],
}
}
function onMouseMove(e: MouseEvent) {
if (dragMode.value === 'idle') return
const p = pointFromEvent(e)
if (dragMode.value === 'creating') {
rect.value = normalizeRect(dragAnchor.value, p)
} else if (dragMode.value === 'moving' && rect.value) {
rect.value = {
...rect.value,
x: clamp(p.x - dragAnchor.value.x, 0, props.width - rect.value.w),
y: clamp(p.y - dragAnchor.value.y, 0, props.height - rect.value.h),
}
} else if (dragMode.value === 'resizing') {
resizeSelection(p)
} else if (dragMode.value === 'annotating' && draftAnnotation.value) {
if (activeTool.value === 'pen') {
draftAnnotation.value.points.push(localPoint(p))
} else {
draftAnnotation.value.points = [
draftAnnotation.value.points[0],
localPoint(p),
]
}
}
}
function onMouseUp() {
if (dragMode.value === 'creating' && rect.value) {
if (rect.value.w < 4 || rect.value.h < 4) {
rect.value = null
}
}
if (dragMode.value === 'annotating' && draftAnnotation.value) {
if (draftAnnotation.value.points.length > 0) {
annotations.value.push(draftAnnotation.value)
}
draftAnnotation.value = null
}
dragMode.value = 'idle'
resizeHandle.value = null
startRect.value = null
}
function resizeSelection(p: Point) {
if (!startRect.value || !resizeHandle.value) return
const r = startRect.value
let left = r.x
let top = r.y
let right = r.x + r.w
let bottom = r.y + r.h
if (resizeHandle.value.includes('w')) left = p.x
if (resizeHandle.value.includes('e')) right = p.x
if (resizeHandle.value.includes('n')) top = p.y
if (resizeHandle.value.includes('s')) bottom = p.y
left = clamp(left, 0, props.width)
right = clamp(right, 0, props.width)
top = clamp(top, 0, props.height)
bottom = clamp(bottom, 0, props.height)
rect.value = {
x: Math.min(left, right),
y: Math.min(top, bottom),
w: Math.abs(right - left),
h: Math.abs(bottom - top),
}
}
function selectTool(tool: Tool) {
activeTool.value = tool
}
function chooseColor(color: string) {
activeColor.value = color
paletteOpen.value = false
}
function onConfirm() {
emitAction('confirm')
}
function onCopy() {
emitAction('copy')
}
function onSave() {
emitAction('save')
}
function onSaveRemote() {
emitAction('save-remote')
}
function emitAction(action: 'confirm' | 'copy' | 'save' | 'save-remote') {
if (!rect.value) return
const payload = {
rect: { ...rect.value },
annotations: annotations.value,
}
if (action === 'confirm') emit('confirm', payload)
if (action === 'copy') emit('copy', payload)
if (action === 'save') emit('save', payload)
if (action === 'save-remote') emit('save-remote', payload)
}
function onCancel() {
emit('cancel')
}
function onKeydown(e: KeyboardEvent) {
if (e.key === 'Escape') {
e.preventDefault()
onCancel()
} else if (e.key === 'Enter' && rect.value) {
e.preventDefault()
onConfirm()
}
}
function undoAnnotation() {
annotations.value.pop()
}
function removeSinglePointAnnotation() {
const last = annotations.value[annotations.value.length - 1]
if (last && last.tool === activeTool.value && last.points.length <= 1) {
annotations.value.pop()
}
}
onMounted(() => {
window.addEventListener('keydown', onKeydown)
})
onUnmounted(() => {
window.removeEventListener('keydown', onKeydown)
})
</script>
<template>
<div
class="overlay-root"
:style="{ width: width + 'px', height: height + 'px' }"
@mousedown="onMouseDown"
@mousemove="onMouseMove"
@mouseup="onMouseUp"
@contextmenu.prevent="onCancel"
>
<svg
class="mask"
:width="width"
:height="height"
:viewBox="`0 0 ${width} ${height}`"
preserveAspectRatio="none"
>
<path :d="maskPath" fill="rgba(0,0,0,0.45)" fill-rule="evenodd" />
<rect
v-if="rect"
:x="rect.x"
:y="rect.y"
:width="rect.w"
:height="rect.h"
fill="transparent"
stroke="#3b82f6"
stroke-width="1.5"
vector-effect="non-scaling-stroke"
/>
<g v-for="(annotation, index) in selectionAnnotations" :key="index">
<polyline
v-if="annotation.tool === 'pen'"
:points="annotation.points.map((p) => `${p.x},${p.y}`).join(' ')"
:stroke="annotation.color"
stroke-width="3"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<rect
v-else-if="annotation.tool === 'rect' && annotation.points.length >= 2"
:x="Math.min(annotation.points[0].x, annotation.points[1].x)"
:y="Math.min(annotation.points[0].y, annotation.points[1].y)"
:width="Math.abs(annotation.points[1].x - annotation.points[0].x)"
:height="Math.abs(annotation.points[1].y - annotation.points[0].y)"
:stroke="annotation.color"
stroke-width="3"
fill="none"
/>
<ellipse
v-else-if="annotation.tool === 'ellipse' && annotation.points.length >= 2"
:cx="(annotation.points[0].x + annotation.points[1].x) / 2"
:cy="(annotation.points[0].y + annotation.points[1].y) / 2"
:rx="Math.abs(annotation.points[1].x - annotation.points[0].x) / 2"
:ry="Math.abs(annotation.points[1].y - annotation.points[0].y) / 2"
:stroke="annotation.color"
stroke-width="3"
fill="none"
/>
</g>
</svg>
<div
v-if="rect"
class="selection-hit-area"
:style="{
left: rect.x + 'px',
top: rect.y + 'px',
width: rect.w + 'px',
height: rect.h + 'px',
}"
@mousedown="onSelectionMouseDown"
/>
<div
v-for="handle in handles"
:key="handle.name"
class="resize-handle"
:class="`handle-${handle.name}`"
:style="{ left: handle.x + 'px', top: handle.y + 'px' }"
@mousedown.stop="
(event) => {
dragMode = 'resizing'
resizeHandle = handle.name
dragAnchor = pointFromEvent(event)
startRect = rect ? { ...rect } : null
}
"
/>
<div
v-if="rect && rect.w > 0 && rect.h > 0"
class="size-pill"
:style="{
left: rect.x + 'px',
top: Math.max(0, rect.y - 26) + 'px',
}"
>
{{ sizeLabel }}
</div>
<div
v-if="leftToolbarPos && rect && rect.w >= 4 && rect.h >= 4"
class="toolbar mark-toolbar"
:style="{ left: leftToolbarPos.x + 'px', top: leftToolbarPos.y + 'px' }"
@mousedown.stop
>
<button
class="icon-btn"
:class="{ active: activeTool === 'pen' }"
title="划线标记"
@click="selectTool('pen')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M4 20c4-1 6-3 8-7l5-9 3 2-5 9c-2 4-5 6-9 7z" />
<path d="M14 5l5 3" />
</svg>
</button>
<button
class="icon-btn"
:class="{ active: activeTool === 'rect' }"
title="矩形标记"
@click="selectTool('rect')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="5" y="6" width="14" height="12" rx="1.5" />
</svg>
</button>
<button
class="icon-btn"
:class="{ active: activeTool === 'ellipse' }"
title="圆形标记"
@click="selectTool('ellipse')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<circle cx="12" cy="12" r="7" />
</svg>
</button>
<div class="color-wrap">
<button
class="icon-btn color-btn"
title="选择标记颜色"
@click="paletteOpen = !paletteOpen"
>
<span :style="{ background: activeColor }" />
</button>
<div v-if="paletteOpen" class="palette">
<button
v-for="color in colors"
:key="color"
class="swatch"
:class="{ selected: color === activeColor }"
:style="{ background: color }"
:title="color"
@click="chooseColor(color)"
/>
</div>
</div>
<button
class="icon-btn"
:disabled="annotations.length === 0"
title="撤销上一处标记"
@click="undoAnnotation"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 7H4v5" />
<path d="M4 7c3-3 8-4 12-1 4 3 4 9 0 12-2 1-4 2-7 1" />
</svg>
</button>
</div>
<div
v-if="rightToolbarPos && rect && rect.w >= 4 && rect.h >= 4"
class="toolbar action-toolbar"
:style="{ left: rightToolbarPos.x + 'px', top: rightToolbarPos.y + 'px' }"
@mousedown.stop
>
<button
class="action-btn cancel"
data-tip="取消截图"
aria-label="取消截图"
@click="onCancel"
v-html="cancelIcon"
/>
<button
class="action-btn"
data-tip="复制图标"
aria-label="复制图标"
@click="onCopy"
v-html="copyIcon"
/>
<button
class="action-btn"
data-tip="保存本地"
aria-label="保存本地"
@click="onSave"
v-html="saveIcon"
/>
<button
class="action-btn"
data-tip="保存远端"
aria-label="保存远端"
@click="onSaveRemote"
v-html="saveRemoteIcon"
/>
<button
class="action-btn primary"
data-tip="上传云端"
aria-label="上传云端"
@click="onConfirm"
v-html="uploadIcon"
/>
</div>
<div v-if="!rect" class="hint">
Drag to select an area &nbsp;·&nbsp; Esc to cancel
</div>
</div>
</template>
<style scoped>
.overlay-root {
position: fixed;
inset: 0;
user-select: none;
cursor: crosshair;
background: transparent;
overflow: hidden;
}
.mask {
position: absolute;
left: 0;
top: 0;
pointer-events: none;
}
.selection-hit-area {
position: absolute;
cursor: crosshair;
}
.resize-handle {
position: absolute;
width: 10px;
height: 10px;
transform: translate(-50%, -50%);
border: 1px solid #fff;
background: #3b82f6;
box-shadow: 0 1px 5px rgba(0, 0, 0, 0.35);
z-index: 3;
}
.handle-n,
.handle-s {
cursor: ns-resize;
}
.handle-e,
.handle-w {
cursor: ew-resize;
}
.handle-nw,
.handle-se {
cursor: nwse-resize;
}
.handle-ne,
.handle-sw {
cursor: nesw-resize;
}
.size-pill {
position: absolute;
padding: 2px 8px;
font-size: 12px;
font-weight: 500;
color: #fff;
background: rgba(15, 23, 42, 0.78);
border-radius: 4px;
pointer-events: none;
font-variant-numeric: tabular-nums;
}
.toolbar {
position: absolute;
display: flex;
gap: 6px;
align-items: center;
height: 32px;
padding: 4px;
background: rgba(28, 28, 32, 0.94);
border-radius: 8px;
box-shadow: 0 6px 22px rgba(0, 0, 0, 0.4);
cursor: default;
z-index: 4;
}
.mark-toolbar {
width: 190px;
}
.action-toolbar {
width: 180px;
}
.icon-btn,
.action-btn,
.swatch {
font-family: inherit;
}
/*
* .action-btn — Square SVG icon button used in the bottom-right action toolbar.
*
* Design rationale:
* - Mirrors .icon-btn sizing (32px square) so the action toolbar is visually
* balanced with the annotation toolbar.
* - Uses `currentColor` so each instance can override its own hue (e.g. the
* primary upload button is blue) while sharing one base set of rules.
* - The native CSS tooltip is rendered through `::after` driven by the
* `data-tip` attribute. This avoids the system `title` tooltip's slow show
* delay and lets us style/position the bubble consistently.
*/
.action-btn {
position: relative;
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 5px;
color: #d1d5db;
background: transparent;
cursor: pointer;
padding: 0;
}
.action-btn:hover {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.action-btn.cancel:hover {
color: #fca5a5;
background: rgba(239, 68, 68, 0.18);
}
.action-btn.primary {
color: #60a5fa;
}
.action-btn.primary:hover {
color: #fff;
background: #2563eb;
}
.action-btn :deep(svg) {
width: 18px;
height: 18px;
display: block;
fill: currentColor;
pointer-events: none;
}
.action-btn::after {
content: attr(data-tip);
position: absolute;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
padding: 3px 8px;
font-size: 12px;
color: #fff;
background: rgba(15, 23, 42, 0.92);
border-radius: 4px;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.12s ease 0.05s;
z-index: 5;
}
.action-btn:hover::after {
opacity: 1;
}
.icon-btn {
display: grid;
place-items: center;
width: 28px;
height: 28px;
border: 0;
border-radius: 5px;
color: #d1d5db;
background: transparent;
cursor: pointer;
}
.icon-btn:hover:not(:disabled),
.icon-btn.active {
color: #fff;
background: rgba(255, 255, 255, 0.12);
}
.icon-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
}
.icon-btn svg {
width: 18px;
height: 18px;
fill: none;
stroke: currentColor;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
}
.color-wrap {
position: relative;
}
.color-btn span {
width: 16px;
height: 16px;
border: 1px solid rgba(255, 255, 255, 0.7);
box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.18);
}
.palette {
position: absolute;
left: 0;
bottom: 36px;
display: grid;
grid-template-columns: repeat(5, 22px);
gap: 6px;
padding: 8px;
background: rgba(28, 28, 32, 0.96);
border-radius: 8px;
box-shadow: 0 8px 26px rgba(0, 0, 0, 0.45);
}
.swatch {
width: 22px;
height: 22px;
border: 1px solid rgba(255, 255, 255, 0.55);
border-radius: 4px;
cursor: pointer;
}
.swatch.selected {
outline: 2px solid #fff;
outline-offset: 2px;
}
.hint {
position: absolute;
left: 50%;
top: 24px;
transform: translateX(-50%);
padding: 6px 14px;
font-size: 12px;
color: #f3f4f6;
background: rgba(0, 0, 0, 0.5);
border-radius: 999px;
pointer-events: none;
}
</style>