5 Commits

Author SHA1 Message Date
mamamiyear 5aa60555b9 Release beta_v0.1.0
Release Note:

- feature:
  - capture the screenshot, and copy it in clipboard or save it to local file
  - annotate the screenshot with in many ways, e.g. Line, Arrow, Text, Mosaic...
  - upload the screenshot to remote server and copy path, e.g. OBS, SCP, FTP...
  - extract text from the screenshot (OCR), and copy to clipboard
  - describe the screenshot by AI according your requirements

- other:
  - Landing page for promo
  - README.md and AGENTS.md for AI and Humman developers
2026-07-13 20:18:11 +08:00
mamamiyear 75c9b96fbf fix: preserve annotations while resizing selection 2026-07-13 13:43:14 +08:00
mamamiyear b69ef00013 feat: add ftp root path setting 2026-07-13 13:20:31 +08:00
mamamiyear 50551de105 feat: add line and arrow annotations 2026-07-12 21:09:23 +08:00
mamamiyear b82108db38 feat: add mosaic screenshot annotations 2026-07-12 20:40:05 +08:00
15 changed files with 970 additions and 65 deletions
+1
View File
@@ -6,6 +6,7 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vue-tsc --noEmit && vite build", "build": "vue-tsc --noEmit && vite build",
"test": "node --test tests/*.test.ts",
"preview": "vite preview" "preview": "vite preview"
}, },
"dependencies": { "dependencies": {
+1 -1
View File
@@ -1 +1 @@
bb7ffb87329c9ad4990374471d4ce9a4 e2d56b98c3c8ae5968db0bbb461b5615
+1 -1
View File
@@ -68,7 +68,7 @@ const activeTab = ref<SettingsTab>('general')
const sidebarItems: Array<{ id: SettingsTab; label: string }> = [ const sidebarItems: Array<{ id: SettingsTab; label: string }> = [
{ id: 'general', label: '通用设置' }, { id: 'general', label: '通用设置' },
{ id: 's3', label: '对象存储' }, { id: 's3', label: '对象存储' },
{ id: 'ftp', label: 'FTP / SFTP' }, { id: 'ftp', label: '文件服务' },
{ id: 'ssh', label: '远程主机' }, { id: 'ssh', label: '远程主机' },
{ id: 'llm', label: '智能识图' }, { id: 'llm', label: '智能识图' },
{ id: 'ocr', label: '文字提取' }, { id: 'ocr', label: '文字提取' },
+28
View File
@@ -0,0 +1,28 @@
export interface AnnotationPoint {
x: number
y: number
}
interface PointCollection {
points: AnnotationPoint[]
}
// Annotations are stored relative to the selection origin. Resizing from the
// top or left moves that origin, so offset the local points in the opposite
// direction to keep them attached to the same screen pixels.
export function preserveAnnotationScreenPositions(
annotations: PointCollection[],
previousOrigin: AnnotationPoint,
nextOrigin: AnnotationPoint
) {
const dx = previousOrigin.x - nextOrigin.x
const dy = previousOrigin.y - nextOrigin.y
if (dx === 0 && dy === 0) return
for (const annotation of annotations) {
for (const point of annotation.points) {
point.x += dx
point.y += dy
}
}
}
+252 -16
View File
@@ -10,6 +10,7 @@ import saveIcon from '../assets/icons/save-local.svg?raw'
import saveRemoteIcon from '../assets/icons/save-remote.svg?raw' import saveRemoteIcon from '../assets/icons/save-remote.svg?raw'
import ftpIcon from '../assets/icons/ftp.svg?raw' import ftpIcon from '../assets/icons/ftp.svg?raw'
import uploadIcon from '../assets/icons/upload.svg?raw' import uploadIcon from '../assets/icons/upload.svg?raw'
import { preserveAnnotationScreenPositions } from '../utils/annotationGeometry'
interface Props { interface Props {
width: number width: number
@@ -24,7 +25,8 @@ interface Rect {
h: number h: number
} }
type Tool = 'pen' | 'rect' | 'ellipse' | 'text' type Tool = 'pen' | 'line' | 'arrow' | 'rect' | 'ellipse' | 'text' | 'mosaic-brush' | 'mosaic-rect'
type MosaicMode = 'brush' | 'rect'
interface Point { interface Point {
x: number x: number
y: number y: number
@@ -77,6 +79,8 @@ const activeTool = ref<Tool>('pen')
const activeColor = ref('#ef4444') const activeColor = ref('#ef4444')
const activeStrokeWidth = ref(3) const activeStrokeWidth = ref(3)
const activeFontSize = ref(20) const activeFontSize = ref(20)
const mosaicMode = ref<MosaicMode>('brush')
const mosaicBrushWidth = ref(24)
const textDraft = ref<{ const textDraft = ref<{
point: Point point: Point
value: string value: string
@@ -104,6 +108,7 @@ const textDragOriginalPoint = ref<Point | null>(null)
const DEFAULT_TEXT_FONT_SIZE = 20 const DEFAULT_TEXT_FONT_SIZE = 20
const STROKE_WIDTHS = [2, 4, 6] const STROKE_WIDTHS = [2, 4, 6]
const MOSAIC_WIDTHS = [16, 24, 36, 52]
const FONT_SIZES = [16, 20, 28, 36] const FONT_SIZES = [16, 20, 28, 36]
const colors = [ const colors = [
@@ -146,17 +151,18 @@ const sizeLabel = computed(() => {
return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}` return `${Math.round(rect.value.w)} × ${Math.round(rect.value.h)}`
}) })
const MARK_TOOLBAR_W = 178 const MARK_TOOLBAR_W = 280
const ACTION_TOOLBAR_W = 288 const ACTION_TOOLBAR_W = 288
const TOOLBAR_GROUP_GAP = 8 const TOOLBAR_GROUP_GAP = 8
const toolOrder: Tool[] = ['pen', 'rect', 'ellipse', 'text'] const toolOrder: Tool[] = ['pen', 'line', 'arrow', 'rect', 'ellipse', 'text', 'mosaic-brush', 'mosaic-rect']
const toolSettingsPos = computed(() => { const toolSettingsPos = computed(() => {
if (!leftToolbarPos.value || !rect.value || !activeTool.value) return null if (!leftToolbarPos.value || !rect.value || !activeTool.value) return null
const menuW = activeTool.value === 'text' ? 462 : 398 const isMosaic = activeTool.value.startsWith('mosaic-')
const menuW = isMosaic ? (mosaicMode.value === 'brush' ? 328 : 112) : activeTool.value === 'text' ? 462 : 398
const menuH = 58 const menuH = 58
const buttonIndex = toolOrder.indexOf(activeTool.value) const buttonIndex = isMosaic ? 6 : toolOrder.indexOf(activeTool.value)
const buttonCenter = 4 + buttonIndex * 34 + 14 const buttonCenter = 4 + buttonIndex * 34 + 14
let x = leftToolbarPos.value.x let x = leftToolbarPos.value.x
let y = leftToolbarPos.value.y + 44 let y = leftToolbarPos.value.y + 44
@@ -310,6 +316,28 @@ function textRenderBox(annotation: Annotation) {
} }
} }
function arrowHeadPoints(annotation: Annotation) {
if (annotation.points.length < 2) return ''
const start = annotation.points[0]
const end = annotation.points[annotation.points.length - 1]
const dx = end.x - start.x
const dy = end.y - start.y
const length = Math.hypot(dx, dy)
if (length < 1) return ''
const headLength = Math.min(length * 0.45, Math.max(10, (annotation.strokeWidth || 3) * 4))
const angle = Math.atan2(dy, dx)
const spread = Math.PI / 6
const left = {
x: end.x - headLength * Math.cos(angle - spread),
y: end.y - headLength * Math.sin(angle - spread),
}
const right = {
x: end.x - headLength * Math.cos(angle + spread),
y: end.y - headLength * Math.sin(angle + spread),
}
return `${left.x},${left.y} ${end.x},${end.y} ${right.x},${right.y}`
}
function textAnnotationIndexAtPoint(p: Point) { function textAnnotationIndexAtPoint(p: Point) {
if (!rect.value) return null if (!rect.value) return null
for (let i = annotations.value.length - 1; i >= 0; i--) { for (let i = annotations.value.length - 1; i >= 0; i--) {
@@ -432,7 +460,7 @@ function onSelectionMouseDown(e: MouseEvent) {
draftAnnotation.value = { draftAnnotation.value = {
tool: activeTool.value, tool: activeTool.value,
color: activeColor.value, color: activeColor.value,
strokeWidth: activeStrokeWidth.value, strokeWidth: activeTool.value === 'mosaic-brush' ? mosaicBrushWidth.value : activeStrokeWidth.value,
points: [local], points: [local],
} }
} }
@@ -466,7 +494,7 @@ function onMouseMove(e: MouseEvent) {
annotation.points[0] = clampTextLocalPoint(next, annotation) annotation.points[0] = clampTextLocalPoint(next, annotation)
} }
} else if (dragMode.value === 'annotating' && draftAnnotation.value) { } else if (dragMode.value === 'annotating' && draftAnnotation.value) {
if (activeTool.value === 'pen') { if (activeTool.value === 'pen' || activeTool.value === 'mosaic-brush') {
draftAnnotation.value.points.push(localPoint(p)) draftAnnotation.value.points.push(localPoint(p))
} else { } else {
draftAnnotation.value.points = [ draftAnnotation.value.points = [
@@ -512,18 +540,35 @@ function resizeSelection(p: Point) {
right = clamp(right, 0, props.width) right = clamp(right, 0, props.width)
top = clamp(top, 0, props.height) top = clamp(top, 0, props.height)
bottom = clamp(bottom, 0, props.height) bottom = clamp(bottom, 0, props.height)
rect.value = { const nextRect = {
x: Math.min(left, right), x: Math.min(left, right),
y: Math.min(top, bottom), y: Math.min(top, bottom),
w: Math.abs(right - left), w: Math.abs(right - left),
h: Math.abs(bottom - top), h: Math.abs(bottom - top),
} }
if (rect.value) {
preserveAnnotationScreenPositions(annotations.value, rect.value, nextRect)
}
rect.value = nextRect
} }
function selectTool(tool: Tool) { function selectTool(tool: Tool) {
activeTool.value = tool activeTool.value = tool
} }
function selectMosaic() {
activeTool.value = mosaicMode.value === 'brush' ? 'mosaic-brush' : 'mosaic-rect'
}
function chooseMosaicMode(mode: MosaicMode) {
mosaicMode.value = mode
selectMosaic()
}
function chooseMosaicWidth(width: number) {
mosaicBrushWidth.value = width
}
function chooseColor(color: string) { function chooseColor(color: string) {
activeColor.value = color activeColor.value = color
if (textDraft.value) { if (textDraft.value) {
@@ -748,6 +793,13 @@ onUnmounted(() => {
:viewBox="`0 0 ${width} ${height}`" :viewBox="`0 0 ${width} ${height}`"
preserveAspectRatio="none" preserveAspectRatio="none"
> >
<defs>
<pattern id="mosaic-preview" width="10" height="10" patternUnits="userSpaceOnUse">
<rect width="10" height="10" fill="rgba(71,85,105,.82)" />
<rect width="5" height="5" fill="rgba(203,213,225,.82)" />
<rect x="5" y="5" width="5" height="5" fill="rgba(148,163,184,.82)" />
</pattern>
</defs>
<path :d="maskPath" fill="rgba(0,0,0,0.45)" fill-rule="evenodd" /> <path :d="maskPath" fill="rgba(0,0,0,0.45)" fill-rule="evenodd" />
<rect <rect
v-if="rect" v-if="rect"
@@ -770,6 +822,35 @@ onUnmounted(() => {
stroke-linejoin="round" stroke-linejoin="round"
fill="none" fill="none"
/> />
<line
v-else-if="annotation.tool === 'line' && annotation.points.length >= 2"
:x1="annotation.points[0].x"
:y1="annotation.points[0].y"
:x2="annotation.points[annotation.points.length - 1].x"
:y2="annotation.points[annotation.points.length - 1].y"
:stroke="annotation.color"
:stroke-width="annotation.strokeWidth || 3"
stroke-linecap="round"
/>
<g v-else-if="annotation.tool === 'arrow' && annotation.points.length >= 2">
<line
:x1="annotation.points[0].x"
:y1="annotation.points[0].y"
:x2="annotation.points[annotation.points.length - 1].x"
:y2="annotation.points[annotation.points.length - 1].y"
:stroke="annotation.color"
:stroke-width="annotation.strokeWidth || 3"
stroke-linecap="round"
/>
<polyline
:points="arrowHeadPoints(annotation)"
:stroke="annotation.color"
:stroke-width="annotation.strokeWidth || 3"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
</g>
<rect <rect
v-else-if="annotation.tool === 'rect' && annotation.points.length >= 2" v-else-if="annotation.tool === 'rect' && annotation.points.length >= 2"
:x="Math.min(annotation.points[0].x, annotation.points[1].x)" :x="Math.min(annotation.points[0].x, annotation.points[1].x)"
@@ -811,6 +892,25 @@ onUnmounted(() => {
{{ annotation.text }} {{ annotation.text }}
</text> </text>
</template> </template>
<polyline
v-else-if="annotation.tool === 'mosaic-brush'"
:points="annotation.points.map((p) => `${p.x},${p.y}`).join(' ')"
stroke="url(#mosaic-preview)"
:stroke-width="annotation.strokeWidth || 24"
stroke-linecap="round"
stroke-linejoin="round"
fill="none"
/>
<rect
v-else-if="annotation.tool === 'mosaic-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)"
fill="url(#mosaic-preview)"
stroke="rgba(255,255,255,.8)"
stroke-width="1"
/>
</g> </g>
</svg> </svg>
@@ -882,12 +982,37 @@ onUnmounted(() => {
<button <button
class="icon-btn" class="icon-btn"
:class="{ active: activeTool === 'pen' }" :class="{ active: activeTool === 'pen' }"
title="划线标记" title="画笔标记"
aria-label="画笔标记"
@click="selectTool('pen')" @click="selectTool('pen')"
> >
<svg viewBox="0 0 24 24" aria-hidden="true"> <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.5 5.5 4-4 4 4-4 4" />
<path d="M14 5l5 3" /> <path d="m17.5 8.5-7.8 7.8" />
<path d="M11 14.8c.8 2.8-.7 5.5-3.7 6.3-1.8.5-3.7.1-5.3-.9 2.1-.4 2.8-1.5 3.1-3.3.4-2.5 3.3-3.8 5.9-2.1Z" />
</svg>
</button>
<button
class="icon-btn"
:class="{ active: activeTool === 'line' }"
title="直线标记"
aria-label="直线标记"
@click="selectTool('line')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 19 19 5" />
</svg>
</button>
<button
class="icon-btn"
:class="{ active: activeTool === 'arrow' }"
title="箭头标记"
aria-label="箭头标记"
@click="selectTool('arrow')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M5 19 19 5" />
<path d="M11 5h8v8" />
</svg> </svg>
</button> </button>
<button <button
@@ -922,6 +1047,21 @@ onUnmounted(() => {
<path d="M9 19h6" /> <path d="M9 19h6" />
</svg> </svg>
</button> </button>
<button
class="icon-btn"
:class="{ active: activeTool.startsWith('mosaic-') }"
title="马赛克标记"
aria-label="马赛克标记"
:aria-expanded="activeTool.startsWith('mosaic-')"
@click="selectMosaic"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="4" y="4" width="6" height="6" />
<rect x="14" y="4" width="6" height="6" />
<rect x="4" y="14" width="6" height="6" />
<rect x="14" y="14" width="6" height="6" />
</svg>
</button>
<button <button
class="icon-btn" class="icon-btn"
:class="{ muted: annotations.length === 0 }" :class="{ muted: annotations.length === 0 }"
@@ -947,7 +1087,55 @@ onUnmounted(() => {
}" }"
@mousedown.stop @mousedown.stop
> >
<div v-if="activeTool !== 'text'" class="setting-group stroke-group"> <template v-if="activeTool.startsWith('mosaic-')">
<div class="setting-group mosaic-mode-group" role="group" aria-label="马赛克标记方式">
<button
class="mode-choice"
:class="{ active: mosaicMode === 'brush' }"
title="涂抹"
aria-label="涂抹马赛克"
:aria-pressed="mosaicMode === 'brush'"
@click="chooseMosaicMode('brush')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<path d="M9 11V5.5a2 2 0 0 1 4 0V10" />
<path d="M13 10V8a2 2 0 0 1 4 0v3" />
<path d="M17 10a2 2 0 0 1 4 0v4.5c0 4-2.5 6.5-6.5 6.5h-1.2a6 6 0 0 1-4.7-2.3L4 13.2a1.9 1.9 0 0 1 2.8-2.5L9 12.5" />
</svg>
</button>
<button
class="mode-choice"
:class="{ active: mosaicMode === 'rect' }"
title="框选"
aria-label="框选马赛克"
:aria-pressed="mosaicMode === 'rect'"
@click="chooseMosaicMode('rect')"
>
<svg viewBox="0 0 24 24" aria-hidden="true">
<rect x="2.5" y="2.5" width="19" height="19" rx="2" />
<rect class="pixel-block" x="6" y="6" width="4" height="4" rx=".5" />
<rect class="pixel-block" x="14" y="6" width="4" height="4" rx=".5" />
<rect class="pixel-block" x="6" y="14" width="4" height="4" rx=".5" />
<rect class="pixel-block" x="14" y="14" width="4" height="4" rx=".5" />
</svg>
</button>
</div>
<template v-if="mosaicMode === 'brush'">
<div class="settings-divider" />
<span class="setting-label">粗细</span>
<div class="setting-group stroke-group">
<button
v-for="widthValue in MOSAIC_WIDTHS"
:key="widthValue"
class="stroke-choice"
:class="{ active: mosaicBrushWidth === widthValue }"
:title="`${widthValue}px`"
@click="chooseMosaicWidth(widthValue)"
><span :style="{ width: Math.min(22, widthValue / 2) + 'px', height: Math.min(22, widthValue / 2) + 'px' }" /></button>
</div>
</template>
</template>
<div v-else-if="activeTool !== 'text'" class="setting-group stroke-group">
<button <button
v-for="widthValue in STROKE_WIDTHS" v-for="widthValue in STROKE_WIDTHS"
:key="widthValue" :key="widthValue"
@@ -971,8 +1159,8 @@ onUnmounted(() => {
{{ size }} {{ size }}
</button> </button>
</div> </div>
<div class="settings-divider" /> <div v-if="!activeTool.startsWith('mosaic-')" class="settings-divider" />
<div class="setting-group color-group"> <div v-if="!activeTool.startsWith('mosaic-')" class="setting-group color-group">
<button <button
v-for="color in colors" v-for="color in colors"
:key="color" :key="color"
@@ -1171,7 +1359,7 @@ onUnmounted(() => {
} }
.mark-toolbar { .mark-toolbar {
width: 178px; width: 280px;
} }
.action-toolbar { .action-toolbar {
box-sizing: border-box; box-sizing: border-box;
@@ -1183,7 +1371,8 @@ onUnmounted(() => {
.action-btn, .action-btn,
.swatch, .swatch,
.stroke-choice, .stroke-choice,
.font-choice { .font-choice,
.mode-choice {
font-family: inherit; font-family: inherit;
} }
@@ -1341,6 +1530,53 @@ onUnmounted(() => {
background: transparent; background: transparent;
cursor: pointer; cursor: pointer;
} }
.mosaic-mode-group {
gap: 0;
padding: 2px;
border-radius: 7px;
background: #e5e7eb;
}
.mode-choice {
display: grid;
place-items: center;
width: 36px;
min-width: 36px;
height: 30px;
padding: 0;
border: 0;
border-radius: 5px;
color: #475569;
background: transparent;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.mode-choice svg {
width: 19px;
height: 19px;
fill: none;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.mode-choice svg .pixel-block {
fill: currentColor;
stroke: none;
}
.mode-choice:hover,
.mode-choice.active {
color: #1d4ed8;
background: #fff;
box-shadow: 0 1px 3px rgba(15, 23, 42, 0.14);
}
.setting-label {
position: relative;
z-index: 1;
color: #64748b;
font-size: 12px;
white-space: nowrap;
}
.stroke-choice:hover, .stroke-choice:hover,
.stroke-choice.active, .stroke-choice.active,
.font-choice:hover, .font-choice:hover,
+30 -10
View File
@@ -69,6 +69,7 @@ interface FTPConfig {
user: string user: string
authMethod: 'password' | 'key' authMethod: 'password' | 'key'
password: string password: string
rootDirectory: string
pathPrefix: string pathPrefix: string
strictHostKey: boolean strictHostKey: boolean
knownHostsPath: string knownHostsPath: string
@@ -169,6 +170,7 @@ function defaultConfig(): AppConfig {
user: '', user: '',
authMethod: 'password', authMethod: 'password',
password: '', password: '',
rootDirectory: '/',
pathPrefix: 'snapgo/', pathPrefix: 'snapgo/',
strictHostKey: false, strictHostKey: false,
knownHostsPath: '', knownHostsPath: '',
@@ -271,8 +273,8 @@ const sshPathDisplay = computed({
}) })
// FTP and SFTP both upload relative to the authenticated login directory. // FTP and SFTP both upload relative to the authenticated login directory.
// The visible prefix communicates how that location will be copied: FTP uses // RootDirectory is display-only: it is prepended to the uploaded relative path
// its virtual root, while SFTP uses the SSH user's home directory. // when that path is copied, without changing where the file is uploaded.
const ftpPathDisplay = computed({ const ftpPathDisplay = computed({
get: () => config.value.ftp.pathPrefix, get: () => config.value.ftp.pathPrefix,
set: (raw: string) => { set: (raw: string) => {
@@ -283,17 +285,26 @@ const ftpPathDisplay = computed({
}, },
}) })
const ftpPathRootLabel = computed(() => const ftpCopiedPathPreview = computed(() => {
config.value.ftp.protocol === 'sftp' ? '~/' : '/' const root = config.value.ftp.rootDirectory.trim().replace(/\/+$/, '')
) const remote = config.value.ftp.pathPrefix
.trim()
.replace(/^\/+|\/+$/g, '')
const remotePrefix = remote ? `${remote}/` : ''
return `${root || (config.value.ftp.protocol === 'sftp' ? '~' : '')}/${remotePrefix}2026/07/20260713-110413-18lnbx.png`
})
function changeFTPProtocol(event: Event) { function changeFTPProtocol(event: Event) {
const next = (event.target as HTMLSelectElement).value as FTPProtocol const next = (event.target as HTMLSelectElement).value as FTPProtocol
const previous = config.value.ftp.protocol const previous = config.value.ftp.protocol
const previousDefault = previous === 'sftp' ? 22 : 21 const previousDefault = previous === 'sftp' ? 22 : 21
const previousRootDefault = previous === 'sftp' ? '~/' : '/'
if (config.value.ftp.port === previousDefault) { if (config.value.ftp.port === previousDefault) {
config.value.ftp.port = next === 'sftp' ? 22 : 21 config.value.ftp.port = next === 'sftp' ? 22 : 21
} }
if (config.value.ftp.rootDirectory === previousRootDefault) {
config.value.ftp.rootDirectory = next === 'sftp' ? '~/' : '/'
}
config.value.ftp.protocol = next config.value.ftp.protocol = next
} }
@@ -531,7 +542,7 @@ onMounted(load)
<!-- FTP/SFTP tab file-transfer destination for its toolbar action. --> <!-- FTP/SFTP tab file-transfer destination for its toolbar action. -->
<section v-if="props.tab === 'ftp'" class="card"> <section v-if="props.tab === 'ftp'" class="card">
<h2>FTP / SFTP destination</h2> <h2>文件服务</h2>
<div class="grid"> <div class="grid">
<label class="field"> <label class="field">
<span>Protocol</span> <span>Protocol</span>
@@ -581,15 +592,20 @@ onMounted(load)
<input v-model="config.ftp.password" type="password" /> <input v-model="config.ftp.password" type="password" />
</label> </label>
<label class="field full"> <label class="field full">
<span>Remote directory (under login root)</span> <span>根目录仅用于拼接复制路径</span>
<div class="prefixed-input"> <input
<span class="input-prefix">{{ ftpPathRootLabel }}</span> v-model.trim="config.ftp.rootDirectory"
:placeholder="config.ftp.protocol === 'sftp' ? '~/' : '/'"
spellcheck="false"
/>
</label>
<label class="field full">
<span>Remote directory登录目录下的上传目录</span>
<input <input
v-model="ftpPathDisplay" v-model="ftpPathDisplay"
placeholder="snapgo/" placeholder="snapgo/"
spellcheck="false" spellcheck="false"
/> />
</div>
</label> </label>
<label class="field"> <label class="field">
<span>Connect timeout (sec)</span> <span>Connect timeout (sec)</span>
@@ -615,6 +631,10 @@ onMounted(load)
</label> </label>
</div> </div>
<p class="hint">
复制路径示例<code>{{ ftpCopiedPathPreview }}</code>
</p>
<p v-if="config.ftp.protocol === 'ftp'" class="hint warn"> <p v-if="config.ftp.protocol === 'ftp'" class="hint warn">
FTP sends the username, password, and screenshot without encryption. FTP sends the username, password, and screenshot without encryption.
Prefer SFTP on networks you do not fully trust. SFTP is not FTPS. Prefer SFTP on networks you do not fully trust. SFTP is not FTPS.
+65
View File
@@ -0,0 +1,65 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { preserveAnnotationScreenPositions } from '../src/utils/annotationGeometry.ts'
test('keeps annotation points at the same screen position when the origin moves', () => {
const annotations = [
{ points: [{ x: 30, y: 40 }, { x: 80, y: 90 }] },
{ points: [{ x: 12, y: 18 }] },
]
preserveAnnotationScreenPositions(
annotations,
{ x: 100, y: 200 },
{ x: 125, y: 230 }
)
assert.deepEqual(annotations, [
{ points: [{ x: 5, y: 10 }, { x: 55, y: 60 }] },
{ points: [{ x: -13, y: -12 }] },
])
assert.deepEqual(
annotations[0].points.map((point) => ({
x: 125 + point.x,
y: 230 + point.y,
})),
[{ x: 130, y: 240 }, { x: 180, y: 290 }]
)
})
test('does not move annotations when only the right or bottom edge changes', () => {
const annotations = [{ points: [{ x: 30, y: 40 }] }]
preserveAnnotationScreenPositions(
annotations,
{ x: 100, y: 200 },
{ x: 100, y: 200 }
)
assert.deepEqual(annotations, [{ points: [{ x: 30, y: 40 }] }])
})
test('preserves screen positions across repeated drags and an edge crossover', () => {
const annotations = [{ points: [{ x: 50, y: 60 }] }]
preserveAnnotationScreenPositions(
annotations,
{ x: 100, y: 100 },
{ x: 140, y: 130 }
)
preserveAnnotationScreenPositions(
annotations,
{ x: 140, y: 130 },
{ x: 300, y: 150 }
)
assert.deepEqual(annotations, [{ points: [{ x: -150, y: 10 }] }])
assert.deepEqual(
{
x: 300 + annotations[0].points[0].x,
y: 150 + annotations[0].points[0].y,
},
{ x: 150, y: 160 }
)
})
+2
View File
@@ -186,6 +186,7 @@ export namespace domain {
user: string; user: string;
authMethod: string; authMethod: string;
password: string; password: string;
rootDirectory: string;
pathPrefix: string; pathPrefix: string;
strictHostKey: boolean; strictHostKey: boolean;
knownHostsPath: string; knownHostsPath: string;
@@ -203,6 +204,7 @@ export namespace domain {
this.user = source["user"]; this.user = source["user"];
this.authMethod = source["authMethod"]; this.authMethod = source["authMethod"];
this.password = source["password"]; this.password = source["password"];
this.rootDirectory = source["rootDirectory"];
this.pathPrefix = source["pathPrefix"]; this.pathPrefix = source["pathPrefix"];
this.strictHostKey = source["strictHostKey"]; this.strictHostKey = source["strictHostKey"];
this.knownHostsPath = source["knownHostsPath"]; this.knownHostsPath = source["knownHostsPath"];
+123
View File
@@ -75,12 +75,20 @@ func ApplyAnnotationsWithScale(pngBytes []byte, annotations []Annotation, scaleX
switch ann.Tool { switch ann.Tool {
case "pen": case "pen":
drawPolyline(dst, ann.Points, scaleX, scaleY, width, c) drawPolyline(dst, ann.Points, scaleX, scaleY, width, c)
case "line":
drawStraightLine(dst, ann.Points, scaleX, scaleY, width, c)
case "arrow":
drawArrow(dst, ann.Points, scaleX, scaleY, width, c)
case "rect": case "rect":
drawRectOutline(dst, ann.Points, scaleX, scaleY, width, c) drawRectOutline(dst, ann.Points, scaleX, scaleY, width, c)
case "ellipse": case "ellipse":
drawEllipseOutline(dst, ann.Points, scaleX, scaleY, width, c) drawEllipseOutline(dst, ann.Points, scaleX, scaleY, width, c)
case "text": case "text":
drawTextAnnotation(dst, ann, scaleX, scaleY, c) drawTextAnnotation(dst, ann, scaleX, scaleY, c)
case "mosaic-brush":
applyMosaicBrush(dst, ann.Points, scaleX, scaleY, width)
case "mosaic-rect":
applyMosaicRect(dst, ann.Points, scaleX, scaleY)
} }
} }
@@ -91,6 +99,121 @@ func ApplyAnnotationsWithScale(pngBytes []byte, annotations []Annotation, scaleX
return buf.Bytes(), nil return buf.Bytes(), nil
} }
func drawStraightLine(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) {
if len(points) < 2 {
return
}
drawLine(img, scalePoint(points[0], scaleX, scaleY), scalePoint(points[len(points)-1], scaleX, scaleY), width, c)
}
func drawArrow(img *image.RGBA, points []Point, scaleX, scaleY float64, width int, c color.RGBA) {
if len(points) < 2 {
return
}
start := scalePoint(points[0], scaleX, scaleY)
end := scalePoint(points[len(points)-1], scaleX, scaleY)
dx, dy := end.X-start.X, end.Y-start.Y
length := math.Hypot(dx, dy)
if length < 1 {
return
}
drawLine(img, start, end, width, c)
headLength := math.Min(length*0.45, math.Max(10*math.Max(scaleX, scaleY), float64(width)*4))
angle := math.Atan2(dy, dx)
spread := math.Pi / 6
left := Point{X: end.X - headLength*math.Cos(angle-spread), Y: end.Y - headLength*math.Sin(angle-spread)}
right := Point{X: end.X - headLength*math.Cos(angle+spread), Y: end.Y - headLength*math.Sin(angle+spread)}
drawLine(img, end, left, width, c)
drawLine(img, end, right, width, c)
}
const mosaicBlockSize = 10
func applyMosaicBrush(img *image.RGBA, points []Point, scaleX, scaleY float64, width int) {
if len(points) == 0 {
return
}
mask := image.NewAlpha(img.Bounds())
maskColor := color.RGBA{A: 255}
if len(points) == 1 {
drawDotMask(mask, scalePoint(points[0], scaleX, scaleY), width, maskColor)
} else {
for i := 1; i < len(points); i++ {
drawMaskLine(mask, scalePoint(points[i-1], scaleX, scaleY), scalePoint(points[i], scaleX, scaleY), width, maskColor)
}
}
applyPixelation(img, img.Bounds(), mask)
}
func applyMosaicRect(img *image.RGBA, points []Point, scaleX, scaleY float64) {
if len(points) < 2 {
return
}
a := scalePoint(points[0], scaleX, scaleY)
b := scalePoint(points[len(points)-1], scaleX, scaleY)
x1, x2 := ordered(a.X, b.X)
y1, y2 := ordered(a.Y, b.Y)
r := image.Rect(int(math.Floor(x1)), int(math.Floor(y1)), int(math.Ceil(x2)), int(math.Ceil(y2))).Intersect(img.Bounds())
applyPixelation(img, r, nil)
}
func applyPixelation(img *image.RGBA, region image.Rectangle, mask *image.Alpha) {
if region.Empty() {
return
}
for y := region.Min.Y; y < region.Max.Y; y += mosaicBlockSize {
for x := region.Min.X; x < region.Max.X; x += mosaicBlockSize {
block := image.Rect(x, y, min(x+mosaicBlockSize, region.Max.X), min(y+mosaicBlockSize, region.Max.Y))
var r, g, b, a, count uint64
for py := block.Min.Y; py < block.Max.Y; py++ {
for px := block.Min.X; px < block.Max.X; px++ {
c := img.RGBAAt(px, py)
r += uint64(c.R)
g += uint64(c.G)
b += uint64(c.B)
a += uint64(c.A)
count++
}
}
if count == 0 {
continue
}
avg := color.RGBA{uint8(r / count), uint8(g / count), uint8(b / count), uint8(a / count)}
for py := block.Min.Y; py < block.Max.Y; py++ {
for px := block.Min.X; px < block.Max.X; px++ {
if mask == nil || mask.AlphaAt(px, py).A > 0 {
img.SetRGBA(px, py, avg)
}
}
}
}
}
}
func drawMaskLine(mask *image.Alpha, a, b Point, width int, c color.RGBA) {
dx, dy := b.X-a.X, b.Y-a.Y
steps := int(math.Max(math.Abs(dx), math.Abs(dy)))
if steps == 0 {
drawDotMask(mask, a, width, c)
return
}
for i := 0; i <= steps; i++ {
t := float64(i) / float64(steps)
drawDotMask(mask, Point{X: a.X + dx*t, Y: a.Y + dy*t}, width, c)
}
}
func drawDotMask(mask *image.Alpha, p Point, width int, c color.RGBA) {
radius := float64(width) / 2
for y := int(math.Floor(p.Y - radius)); y <= int(math.Ceil(p.Y+radius)); y++ {
for x := int(math.Floor(p.X - radius)); x <= int(math.Ceil(p.X+radius)); x++ {
if image.Pt(x, y).In(mask.Bounds()) && math.Hypot(float64(x)-p.X, float64(y)-p.Y) <= radius {
mask.SetAlpha(x, y, color.Alpha{A: c.A})
}
}
}
}
func parseHexColor(hex string) (color.RGBA, error) { func parseHexColor(hex string) (color.RGBA, error) {
value := strings.TrimPrefix(strings.TrimSpace(hex), "#") value := strings.TrimPrefix(strings.TrimSpace(hex), "#")
if len(value) != 6 { if len(value) != 6 {
+107
View File
@@ -171,3 +171,110 @@ func TestApplyAnnotationsUsesStrokeWidth(t *testing.T) {
t.Fatalf("expected thick rectangle stroke to cover y=24, got %#v", got) t.Fatalf("expected thick rectangle stroke to cover y=24, got %#v", got)
} }
} }
func TestApplyAnnotationsPixelatesMosaicRectangle(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 40, 30))
for y := 0; y < 30; y++ {
for x := 0; x < 40; x++ {
src.SetRGBA(x, y, color.RGBA{R: uint8(x * 6), G: uint8(y * 8), B: uint8((x + y) * 3), A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, src); err != nil {
t.Fatalf("encode source: %v", err)
}
out, err := ApplyAnnotations(buf.Bytes(), []Annotation{{
Tool: "mosaic-rect", Points: []Point{{X: 10, Y: 5}, {X: 30, Y: 25}},
}}, 1)
if err != nil {
t.Fatalf("apply annotations: %v", err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode output: %v", err)
}
if img.At(12, 7) != img.At(18, 13) {
t.Fatalf("expected pixels in one mosaic block to share a color")
}
if img.At(5, 5) != src.At(5, 5) {
t.Fatalf("expected pixels outside mosaic rectangle to remain unchanged")
}
}
func TestApplyAnnotationsPixelatesOnlyMosaicBrushStroke(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 50, 30))
for y := 0; y < 30; y++ {
for x := 0; x < 50; x++ {
src.SetRGBA(x, y, color.RGBA{R: uint8(x * 5), G: uint8(y * 8), A: 255})
}
}
var buf bytes.Buffer
if err := png.Encode(&buf, src); err != nil {
t.Fatalf("encode source: %v", err)
}
out, err := ApplyAnnotations(buf.Bytes(), []Annotation{{
Tool: "mosaic-brush", StrokeWidth: 12, Points: []Point{{X: 5, Y: 15}, {X: 45, Y: 15}},
}}, 1)
if err != nil {
t.Fatalf("apply annotations: %v", err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode output: %v", err)
}
if img.At(12, 15) == src.At(12, 15) {
t.Fatalf("expected brush path to be pixelated")
}
if img.At(12, 2) != src.At(12, 2) {
t.Fatalf("expected pixels outside brush path to remain unchanged")
}
}
func TestApplyAnnotationsDrawsStraightLine(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 60, 40))
draw.Draw(src, src.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, src); err != nil {
t.Fatalf("encode source: %v", err)
}
out, err := ApplyAnnotations(buf.Bytes(), []Annotation{{
Tool: "line", Color: "#ef4444", StrokeWidth: 4,
Points: []Point{{X: 5, Y: 20}, {X: 50, Y: 20}},
}}, 1)
if err != nil {
t.Fatalf("apply annotations: %v", err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode output: %v", err)
}
got := color.RGBAModel.Convert(img.At(30, 20)).(color.RGBA)
if got.R < 180 || got.G > 180 || got.B > 180 {
t.Fatalf("expected red line at center, got %#v", got)
}
}
func TestApplyAnnotationsDrawsArrowHead(t *testing.T) {
src := image.NewRGBA(image.Rect(0, 0, 80, 60))
draw.Draw(src, src.Bounds(), &image.Uniform{C: color.White}, image.Point{}, draw.Src)
var buf bytes.Buffer
if err := png.Encode(&buf, src); err != nil {
t.Fatalf("encode source: %v", err)
}
out, err := ApplyAnnotations(buf.Bytes(), []Annotation{{
Tool: "arrow", Color: "#3b82f6", StrokeWidth: 3,
Points: []Point{{X: 10, Y: 30}, {X: 60, Y: 30}},
}}, 1)
if err != nil {
t.Fatalf("apply annotations: %v", err)
}
img, err := png.Decode(bytes.NewReader(out))
if err != nil {
t.Fatalf("decode output: %v", err)
}
shaft := color.RGBAModel.Convert(img.At(35, 30)).(color.RGBA)
head := color.RGBAModel.Convert(img.At(52, 25)).(color.RGBA)
if shaft.B < 180 || shaft.R > 140 || head.B < 180 || head.R > 140 {
t.Fatalf("expected blue arrow shaft and head, got shaft=%#v head=%#v", shaft, head)
}
}
+12 -7
View File
@@ -40,9 +40,9 @@ type CaptureAndFTPService struct {
Cfg domain.FTPConfig Cfg domain.FTPConfig
} }
// ExecuteWithBytes uploads previously captured PNG bytes and copies the exact // ExecuteWithBytes uploads previously captured PNG bytes and copies a display
// remote path semantics shown in Settings: FTP paths are rooted at "/", while // path built from the configured root directory plus the uploaded relative
// SFTP paths are relative to the authenticated user's home directory. // path. RootDirectory deliberately does not affect the upload destination.
func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error { func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []byte) error {
if s.Uploader == nil { if s.Uploader == nil {
s.notifyFailure("FTP/SFTP not configured") s.notifyFailure("FTP/SFTP not configured")
@@ -80,7 +80,7 @@ func (s *CaptureAndFTPService) ExecuteWithBytes(ctx context.Context, pngBytes []
return err return err
} }
clipText := ftpShareText(protocol, relPath) clipText := ftpShareText(protocol, s.Cfg.RootDirectory, relPath)
if s.Clipboard != nil { if s.Clipboard != nil {
if err := s.Clipboard.WriteText(clipText); err != nil { if err := s.Clipboard.WriteText(clipText); err != nil {
s.notifyFailure("clipboard write failed: " + err.Error()) s.notifyFailure("clipboard write failed: " + err.Error())
@@ -131,11 +131,16 @@ func normalizedFTPProtocol(protocol string) string {
return domain.FTPProtocolFTP return domain.FTPProtocolFTP
} }
func ftpShareText(protocol, relPath string) string { func ftpShareText(protocol, rootDirectory, relPath string) string {
rootDirectory = strings.TrimSpace(rootDirectory)
if rootDirectory == "" {
if protocol == domain.FTPProtocolSFTP { if protocol == domain.FTPProtocolSFTP {
return "~/" + relPath rootDirectory = "~/"
} else {
rootDirectory = "/"
} }
return "/" + relPath }
return strings.TrimRight(rootDirectory, "/") + "/" + strings.TrimLeft(relPath, "/")
} }
func (s *CaptureAndFTPService) notifyFailure(reason string) { func (s *CaptureAndFTPService) notifyFailure(reason string) {
+32 -3
View File
@@ -52,7 +52,7 @@ func TestCaptureAndFTPServiceUploadsAndCopiesFTPPath(t *testing.T) {
} }
} }
func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) { func TestCaptureAndFTPServiceCopiesConfiguredRootPath(t *testing.T) {
uploader := &fakeFTPUploader{} uploader := &fakeFTPUploader{}
clip := &fakeClipboard{} clip := &fakeClipboard{}
svc := &CaptureAndFTPService{ svc := &CaptureAndFTPService{
@@ -60,6 +60,7 @@ func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) {
Clipboard: clip, Clipboard: clip,
Cfg: domain.FTPConfig{ Cfg: domain.FTPConfig{
Protocol: domain.FTPProtocolSFTP, Protocol: domain.FTPProtocolSFTP,
RootDirectory: "~/ftp/",
PathPrefix: "images", PathPrefix: "images",
}, },
} }
@@ -67,8 +68,36 @@ func TestCaptureAndFTPServiceCopiesSFTPHomePath(t *testing.T) {
if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil { if err := svc.ExecuteWithBytes(context.Background(), []byte("png")); err != nil {
t.Fatalf("upload SFTP screenshot: %v", err) t.Fatalf("upload SFTP screenshot: %v", err)
} }
if clip.text != "~/"+uploader.path { if clip.text != "~/ftp/"+uploader.path {
t.Fatalf("expected SFTP home path copied, got %q", clip.text) t.Fatalf("expected configured SFTP root path copied, got %q", clip.text)
}
}
func TestFTPShareTextJoinsConfiguredRootAndRemoteDirectory(t *testing.T) {
const relPath = "snapgo/2026/07/20260713-110413-18lnbx.png"
const want = "~/ftp/snapgo/2026/07/20260713-110413-18lnbx.png"
if got := ftpShareText(domain.FTPProtocolSFTP, "~/ftp/", relPath); got != want {
t.Fatalf("ftpShareText() = %q, want %q", got, want)
}
}
func TestFTPShareTextUsesProtocolDefaultWhenRootDirectoryIsEmpty(t *testing.T) {
const relPath = "snapgo/2026/07/image.png"
tests := []struct {
name string
protocol string
want string
}{
{name: "FTP", protocol: domain.FTPProtocolFTP, want: "/" + relPath},
{name: "SFTP", protocol: domain.FTPProtocolSFTP, want: "~/" + relPath},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ftpShareText(tt.protocol, "", relPath); got != tt.want {
t.Fatalf("ftpShareText() = %q, want %q", got, tt.want)
}
})
} }
} }
+13
View File
@@ -83,6 +83,10 @@ type SSHConfig struct {
// reuses the local ssh-agent and ~/.ssh/id_* discovery used by SSH/SCP. // reuses the local ssh-agent and ~/.ssh/id_* discovery used by SSH/SCP.
// - PathPrefix is relative to the account's login root. The application // - PathPrefix is relative to the account's login root. The application
// rejects absolute paths and traversal segments before uploading. // rejects absolute paths and traversal segments before uploading.
// - RootDirectory is only used to build the path copied to the clipboard.
// Keeping it separate from PathPrefix lets the server expose a login root
// such as "~/ftp/" while uploads still target a relative directory such
// as "snapgo/".
// - StrictHostKey and KnownHostsPath apply only to SFTP. Plain FTP has no // - StrictHostKey and KnownHostsPath apply only to SFTP. Plain FTP has no
// host-key mechanism and sends credentials and data without encryption. // host-key mechanism and sends credentials and data without encryption.
type FTPConfig struct { type FTPConfig struct {
@@ -92,6 +96,7 @@ type FTPConfig struct {
User string `json:"user"` User string `json:"user"`
AuthMethod string `json:"authMethod"` AuthMethod string `json:"authMethod"`
Password string `json:"password"` Password string `json:"password"`
RootDirectory string `json:"rootDirectory"`
PathPrefix string `json:"pathPrefix"` PathPrefix string `json:"pathPrefix"`
StrictHostKey bool `json:"strictHostKey"` StrictHostKey bool `json:"strictHostKey"`
KnownHostsPath string `json:"knownHostsPath"` KnownHostsPath string `json:"knownHostsPath"`
@@ -260,6 +265,7 @@ func DefaultAppConfig() AppConfig {
Protocol: FTPProtocolFTP, Protocol: FTPProtocolFTP,
Port: 21, Port: 21,
AuthMethod: SSHAuthPassword, AuthMethod: SSHAuthPassword,
RootDirectory: "/",
PathPrefix: "snapgo/", PathPrefix: "snapgo/",
ConnectTimeoutSecs: 10, ConnectTimeoutSecs: 10,
StrictHostKey: false, StrictHostKey: false,
@@ -371,6 +377,13 @@ func (c *AppConfig) Normalize() {
if c.FTP.AuthMethod != SSHAuthPassword && c.FTP.AuthMethod != SSHAuthKey { if c.FTP.AuthMethod != SSHAuthPassword && c.FTP.AuthMethod != SSHAuthKey {
c.FTP.AuthMethod = SSHAuthPassword c.FTP.AuthMethod = SSHAuthPassword
} }
if c.FTP.RootDirectory == "" {
if c.FTP.Protocol == FTPProtocolSFTP {
c.FTP.RootDirectory = "~/"
} else {
c.FTP.RootDirectory = "/"
}
}
if c.FTP.PathPrefix == "" { if c.FTP.PathPrefix == "" {
c.FTP.PathPrefix = "snapgo/" c.FTP.PathPrefix = "snapgo/"
} }
+6
View File
@@ -13,6 +13,9 @@ func TestDefaultAppConfigIncludesFTPDefaults(t *testing.T) {
if cfg.FTP.PathPrefix != "snapgo/" { if cfg.FTP.PathPrefix != "snapgo/" {
t.Fatalf("expected default path prefix, got %q", cfg.FTP.PathPrefix) t.Fatalf("expected default path prefix, got %q", cfg.FTP.PathPrefix)
} }
if cfg.FTP.RootDirectory != "/" {
t.Fatalf("expected default root directory, got %q", cfg.FTP.RootDirectory)
}
if cfg.FTP.ConnectTimeoutSecs != 10 { if cfg.FTP.ConnectTimeoutSecs != 10 {
t.Fatalf("expected 10 second timeout, got %d", cfg.FTP.ConnectTimeoutSecs) t.Fatalf("expected 10 second timeout, got %d", cfg.FTP.ConnectTimeoutSecs)
} }
@@ -31,6 +34,9 @@ func TestNormalizeUsesSFTPDefaultPort(t *testing.T) {
if cfg.FTP.AuthMethod != SSHAuthPassword { if cfg.FTP.AuthMethod != SSHAuthPassword {
t.Fatalf("expected password auth default, got %q", cfg.FTP.AuthMethod) t.Fatalf("expected password auth default, got %q", cfg.FTP.AuthMethod)
} }
if cfg.FTP.RootDirectory != "~/" {
t.Fatalf("expected SFTP home root directory, got %q", cfg.FTP.RootDirectory)
}
} }
func TestIsFTPConfiguredRequiresProtocolHostAndUser(t *testing.T) { func TestIsFTPConfiguredRequiresProtocolHostAndUser(t *testing.T) {
+289 -19
View File
@@ -142,8 +142,14 @@ static id nativeOverlayKeyMonitor = nil;
@property(strong) NSColor *activeColor; @property(strong) NSColor *activeColor;
@property CGFloat activeStrokeWidth; @property CGFloat activeStrokeWidth;
@property CGFloat activeFontSize; @property CGFloat activeFontSize;
@property CGFloat mosaicBrushWidth;
@property NSString *mosaicMode;
@property(strong) NSMutableArray<NSDictionary *> *annotations; @property(strong) NSMutableArray<NSDictionary *> *annotations;
@property(strong) NSMutableDictionary *draftAnnotation; @property(strong) NSMutableDictionary *draftAnnotation;
// A downsampled snapshot of the screen beneath the overlay. Drawing this
// image back at full size with interpolation disabled produces a genuine
// pixel-block preview instead of a translucent placeholder stroke.
@property(strong) NSImage *mosaicPreviewImage;
@property(strong) NSButton *cancelButton; @property(strong) NSButton *cancelButton;
// Use `clipboardButton` (not `copyButton`) to avoid ARC's "copy" method // Use `clipboardButton` (not `copyButton`) to avoid ARC's "copy" method
// family ownership rule which would otherwise treat the synthesized getter // family ownership rule which would otherwise treat the synthesized getter
@@ -156,15 +162,20 @@ static id nativeOverlayKeyMonitor = nil;
@property(strong) NSButton *summaryButton; @property(strong) NSButton *summaryButton;
@property(strong) NSButton *uploadButton; @property(strong) NSButton *uploadButton;
@property(strong) NSButton *penButton; @property(strong) NSButton *penButton;
@property(strong) NSButton *lineButton;
@property(strong) NSButton *arrowButton;
@property(strong) NSButton *rectButton; @property(strong) NSButton *rectButton;
@property(strong) NSButton *ellipseButton; @property(strong) NSButton *ellipseButton;
@property(strong) NSButton *textButton; @property(strong) NSButton *textButton;
@property(strong) NSButton *mosaicButton;
@property(strong) NSButton *undoButton; @property(strong) NSButton *undoButton;
@property(strong) SnipToolSettingsView *toolSettingsView; @property(strong) SnipToolSettingsView *toolSettingsView;
@property(strong) NSView *settingsDivider; @property(strong) NSView *settingsDivider;
@property(strong) NSMutableArray<NSButton *> *strokeSettingButtons; @property(strong) NSMutableArray<NSButton *> *strokeSettingButtons;
@property(strong) NSMutableArray<NSButton *> *fontSettingButtons; @property(strong) NSMutableArray<NSButton *> *fontSettingButtons;
@property(strong) NSMutableArray<NSButton *> *colorSettingButtons; @property(strong) NSMutableArray<NSButton *> *colorSettingButtons;
@property(strong) NSMutableArray<NSButton *> *mosaicModeButtons;
@property(strong) NSMutableArray<NSButton *> *mosaicWidthButtons;
@property(strong) NSView *actionToolbarBg; @property(strong) NSView *actionToolbarBg;
@property(strong) NSTextField *textEditor; @property(strong) NSTextField *textEditor;
@property NSPoint textEditorLocalPoint; @property NSPoint textEditorLocalPoint;
@@ -195,6 +206,8 @@ static id nativeOverlayKeyMonitor = nil;
- (void)selectStrokeWidth:(NSButton *)sender; - (void)selectStrokeWidth:(NSButton *)sender;
- (void)selectFontSize:(NSButton *)sender; - (void)selectFontSize:(NSButton *)sender;
- (void)selectToolColor:(NSButton *)sender; - (void)selectToolColor:(NSButton *)sender;
- (void)selectMosaicMode:(NSButton *)sender;
- (void)selectMosaicWidth:(NSButton *)sender;
- (BOOL)isEditingText; - (BOOL)isEditingText;
- (void)commitTextEditor; - (void)commitTextEditor;
- (void)cancelTextEditor; - (void)cancelTextEditor;
@@ -214,6 +227,8 @@ static id nativeOverlayKeyMonitor = nil;
_activeColor = [NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0]; _activeColor = [NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0];
_activeStrokeWidth = 3.0; _activeStrokeWidth = 3.0;
_activeFontSize = 20.0; _activeFontSize = 20.0;
_mosaicBrushWidth = 24.0;
_mosaicMode = @"brush";
_annotations = [NSMutableArray array]; _annotations = [NSMutableArray array];
_textEditorAnnotationIndex = -1; _textEditorAnnotationIndex = -1;
_selectedTextAnnotationIndex = -1; _selectedTextAnnotationIndex = -1;
@@ -240,15 +255,20 @@ static id nativeOverlayKeyMonitor = nil;
[_summaryButton setToolTip:@"复制总结"]; [_summaryButton setToolTip:@"复制总结"];
[_uploadButton setToolTip:@"上传 S3"]; [_uploadButton setToolTip:@"上传 S3"];
_penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)]; _penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)];
_lineButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectLine)];
_arrowButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectArrow)];
_rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)]; _rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)];
_ellipseButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectEllipse)]; _ellipseButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectEllipse)];
_textButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectText)]; _textButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectText)];
_mosaicButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectMosaic)];
_undoButton = [NSButton buttonWithTitle:@"" target:self action:@selector(undoAnnotation)]; _undoButton = [NSButton buttonWithTitle:@"" target:self action:@selector(undoAnnotation)];
_toolSettingsView = [[SnipToolSettingsView alloc] initWithFrame:NSZeroRect]; _toolSettingsView = [[SnipToolSettingsView alloc] initWithFrame:NSZeroRect];
_settingsDivider = [[NSView alloc] initWithFrame:NSZeroRect]; _settingsDivider = [[NSView alloc] initWithFrame:NSZeroRect];
_strokeSettingButtons = [NSMutableArray array]; _strokeSettingButtons = [NSMutableArray array];
_fontSettingButtons = [NSMutableArray array]; _fontSettingButtons = [NSMutableArray array];
_colorSettingButtons = [NSMutableArray array]; _colorSettingButtons = [NSMutableArray array];
_mosaicModeButtons = [NSMutableArray array];
_mosaicWidthButtons = [NSMutableArray array];
// Dark rounded background container behind the action icon buttons, // Dark rounded background container behind the action icon buttons,
// mirroring the look of `.action-toolbar` in the Vue overlay. // mirroring the look of `.action-toolbar` in the Vue overlay.
_actionToolbarBg = [[NSView alloc] initWithFrame:NSZeroRect]; _actionToolbarBg = [[NSView alloc] initWithFrame:NSZeroRect];
@@ -262,10 +282,10 @@ static id nativeOverlayKeyMonitor = nil;
// Add the action toolbar background BEFORE the buttons so it sits // Add the action toolbar background BEFORE the buttons so it sits
// behind them in the view hierarchy (AppKit z-order = subview order). // behind them in the view hierarchy (AppKit z-order = subview order).
[self addSubview:_actionToolbarBg]; [self addSubview:_actionToolbarBg];
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ftpButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _textButton, _undoButton, _toolSettingsView, _sizeLabel, _hintLabel]) { for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ftpButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _lineButton, _arrowButton, _rectButton, _ellipseButton, _textButton, _mosaicButton, _undoButton, _toolSettingsView, _sizeLabel, _hintLabel]) {
[self addSubview:view]; [self addSubview:view];
} }
for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ftpButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _textButton, _undoButton, _toolSettingsView, _actionToolbarBg]) { for (NSView *view in @[_cancelButton, _clipboardButton, _saveButton, _saveRemoteButton, _ftpButton, _ocrButton, _summaryButton, _uploadButton, _penButton, _lineButton, _arrowButton, _rectButton, _ellipseButton, _textButton, _mosaicButton, _undoButton, _toolSettingsView, _actionToolbarBg]) {
[view setHidden:YES]; [view setHidden:YES];
} }
[_sizeLabel setHidden:YES]; [_sizeLabel setHidden:YES];
@@ -377,10 +397,17 @@ static id nativeOverlayKeyMonitor = nil;
summaryHB.baseColor = baseWhite; summaryHB.hoverColor = hoverBlue; summaryHB.baseColor = baseWhite; summaryHB.hoverColor = hoverBlue;
uploadHB.baseColor = baseWhite; uploadHB.hoverColor = hoverBlue; uploadHB.baseColor = baseWhite; uploadHB.hoverColor = hoverBlue;
[self styleIconButton:_penButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M742.72 752.064c-29.696 28.576-42.976 48.96-42.976 77.12 0 98.4 143.776 142.464 229.728 65.536l-21.344-23.84c-66.976 59.936-176.384 26.4-176.384-41.696 0-16.832 9.28-31.104 33.184-54.08l13.44-12.736c61.024-58.016 75.328-102.72 29.312-179.84-43.84-73.44-96.8-88.288-162.784-50.56-47.232 27.04-68.64 47.456-208.32 190.4-70.624 72.288-153.92 77.088-217.344 26.784-59.712-47.328-79.872-127.36-42.176-188.64 24.512-39.84 62.656-72.896 136.64-124.48 5.952-4.192 27.04-18.816 31.104-21.664 12.16-8.448 21.536-15.104 30.4-21.536 107.552-78.272 140.8-139.136 86.048-219.904-76.992-113.472-202.304-90.016-367.744 61.472l21.6 23.616c153.088-140.224 256.896-159.616 319.68-67.104 41.632 61.408 16.896 106.688-78.4 176.032-8.64 6.304-17.92 12.832-29.888 21.184l-31.136 21.632c-77.504 54.08-117.984 89.184-145.536 133.984-46.784 76.032-22.176 173.664 49.536 230.496 76.224 60.416 177.952 54.56 260.096-29.504 136.16-139.36 158.08-160.224 201.312-184.96 50.656-28.96 84.416-19.52 119.424 39.168 36.896 61.824 27.52 91.392-23.808 140.16 0.096-0.064-10.976 10.368-13.632 12.96z' fill='white'/></svg>"]]; [self styleIconButton:_penButton image:[self iconFromSVG:@"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><g fill='none' stroke='white' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='m14.5 5.5 4-4 4 4-4 4'/><path d='m17.5 8.5-7.8 7.8'/><path d='M11 14.8c.8 2.8-.7 5.5-3.7 6.3-1.8.5-3.7.1-5.3-.9 2.1-.4 2.8-1.5 3.1-3.3.4-2.5 3.3-3.8 5.9-2.1Z'/></g></svg>"]];
[self styleIconButton:_lineButton image:[self iconFromSVG:@"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><path d='M5 19 19 5' fill='none' stroke='white' stroke-width='2' stroke-linecap='round'/></svg>"]];
[self styleIconButton:_arrowButton image:[self iconFromSVG:@"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><g fill='none' stroke='white' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'><path d='M5 19 19 5'/><path d='M11 5h8v8'/></g></svg>"]];
[_penButton setToolTip:@"画笔标记"];
[_lineButton setToolTip:@"直线标记"];
[_arrowButton setToolTip:@"箭头标记"];
[self styleIconButton:_rectButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M96 96h832v832H96V96z m32 32v768h768V128H128z' fill='white'/></svg>"]]; [self styleIconButton:_rectButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M96 96h832v832H96V96z m32 32v768h768V128H128z' fill='white'/></svg>"]];
[self styleIconButton:_ellipseButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M512 928c229.76 0 416-186.24 416-416S741.76 96 512 96 96 282.24 96 512s186.24 416 416 416z m0-32C299.936 896 128 724.064 128 512S299.936 128 512 128s384 171.936 384 384-171.936 384-384 384z' fill='white'/></svg>"]]; [self styleIconButton:_ellipseButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M512 928c229.76 0 416-186.24 416-416S741.76 96 512 96 96 282.24 96 512s186.24 416 416 416z m0-32C299.936 896 128 724.064 128 512S299.936 128 512 128s384 171.936 384 384-171.936 384-384 384z' fill='white'/></svg>"]];
[self styleIconButton:_textButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M224 160h576v96H560v608h-96V256H224V160zM384 864h256v64H384v-64z' fill='white'/></svg>"]]; [self styleIconButton:_textButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M224 160h576v96H560v608h-96V256H224V160zM384 864h256v64H384v-64z' fill='white'/></svg>"]];
[self styleIconButton:_mosaicButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><path d='M128 128h320v320H128V128zm448 0h320v320H576V128zM128 576h320v320H128V576zm448 0h320v320H576V576z' fill='white'/></svg>"]];
[_mosaicButton setToolTip:@"马赛克标记"];
[self styleIconButton:_undoButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><g transform='translate(1024 0) scale(-1 1)'><path d='M838.976 288l-169.344-162.56a16 16 0 1 1 22.144-23.04l213.632 204.992-213.312 215.84a16 16 0 0 1-22.784-22.464L847.936 320H454.56c-164.192 0-297.28 128.96-297.28 288s133.088 288 297.28 288H704v32h-242.784C266.88 928 128 784.736 128 608S266.88 288 461.248 288h377.728z' fill='white'/></g></svg>"]]; [self styleIconButton:_undoButton image:[self iconFromSVG:@"<svg viewBox='0 0 1024 1024' xmlns='http://www.w3.org/2000/svg'><g transform='translate(1024 0) scale(-1 1)'><path d='M838.976 288l-169.344-162.56a16 16 0 1 1 22.144-23.04l213.632 204.992-213.312 215.84a16 16 0 0 1-22.784-22.464L847.936 320H454.56c-164.192 0-297.28 128.96-297.28 288s133.088 288 297.28 288H704v32h-242.784C266.88 928 128 784.736 128 608S266.88 288 461.248 288h377.728z' fill='white'/></g></svg>"]];
[_toolSettingsView setWantsLayer:NO]; [_toolSettingsView setWantsLayer:NO];
@@ -415,6 +442,37 @@ static id nativeOverlayKeyMonitor = nil;
[_toolSettingsView addSubview:button]; [_toolSettingsView addSubview:button];
} }
for (NSUInteger i = 0; i < 2; i++) {
NSButton *button = [NSButton buttonWithTitle:@"" target:self action:@selector(selectMosaicMode:)];
[button setTag:(NSInteger)i];
[button setBordered:NO];
[button setWantsLayer:YES];
[[button layer] setCornerRadius:5];
NSString *svg = i == 0
? @"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><g fill='none' stroke='black' stroke-width='1.8' stroke-linecap='round' stroke-linejoin='round'><path d='M9 11V5.5a2 2 0 0 1 4 0V10'/><path d='M13 10V8a2 2 0 0 1 4 0v3'/><path d='M17 10a2 2 0 0 1 4 0v4.5c0 4-2.5 6.5-6.5 6.5h-1.2a6 6 0 0 1-4.7-2.3L4 13.2a1.9 1.9 0 0 1 2.8-2.5L9 12.5'/></g></svg>"
: @"<svg viewBox='0 0 24 24' xmlns='http://www.w3.org/2000/svg'><g fill='none' stroke='black' stroke-width='1.8' stroke-linejoin='round'><rect x='2.5' y='2.5' width='19' height='19' rx='2'/></g><g fill='black'><rect x='6' y='6' width='4' height='4' rx='.5'/><rect x='14' y='6' width='4' height='4' rx='.5'/><rect x='6' y='14' width='4' height='4' rx='.5'/><rect x='14' y='14' width='4' height='4' rx='.5'/></g></svg>";
[button setImage:[self iconFromSVG:svg]];
[button setImagePosition:NSImageOnly];
[button setImageScaling:NSImageScaleProportionallyDown];
NSString *accessibilityLabel = i == 0 ? @"涂抹马赛克" : @"框选马赛克";
[button setToolTip:accessibilityLabel];
[button setAccessibilityLabel:accessibilityLabel];
[_mosaicModeButtons addObject:button];
[_toolSettingsView addSubview:button];
}
for (NSNumber *width in @[@16, @24, @36, @52]) {
NSButton *button = [NSButton buttonWithTitle:@"●" target:self action:@selector(selectMosaicWidth:)];
[button setTag:[width integerValue]];
[button setBordered:NO];
[button setWantsLayer:YES];
[[button layer] setCornerRadius:6];
NSMutableAttributedString *title = [[NSMutableAttributedString alloc] initWithString:@"●"];
[title addAttribute:NSFontAttributeName value:[NSFont systemFontOfSize:MIN(22, 6 + [width doubleValue] / 3) weight:NSFontWeightBold] range:NSMakeRange(0, title.length)];
[button setAttributedTitle:title];
[_mosaicWidthButtons addObject:button];
[_toolSettingsView addSubview:button];
}
NSArray *colors = @[ NSArray *colors = @[
[NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0], [NSColor colorWithCalibratedRed:239.0/255.0 green:68.0/255.0 blue:68.0/255.0 alpha:1.0],
[NSColor colorWithCalibratedRed:250.0/255.0 green:204.0/255.0 blue:21.0/255.0 alpha:1.0], [NSColor colorWithCalibratedRed:250.0/255.0 green:204.0/255.0 blue:21.0/255.0 alpha:1.0],
@@ -629,6 +687,63 @@ static id nativeOverlayKeyMonitor = nil;
} }
continue; continue;
} }
if ([tool hasPrefix:@"mosaic-"]) {
[NSGraphicsContext saveGraphicsState];
NSBezierPath *clipPath = nil;
if ([tool isEqualToString:@"mosaic-brush"]) {
clipPath = [NSBezierPath bezierPath];
CGFloat radius = [self strokeWidthForItem:item] / 2.0;
for (NSUInteger i = 0; i < points.count; i++) {
NSPoint local = [points[i] pointValue];
NSPoint p = NSMakePoint(_selection.origin.x + local.x, _selection.origin.y + local.y);
[clipPath appendBezierPathWithOvalInRect:NSMakeRect(p.x - radius, p.y - radius, radius * 2, radius * 2)];
if (i == 0) continue;
NSPoint previousLocal = [points[i - 1] pointValue];
NSPoint previous = NSMakePoint(_selection.origin.x + previousLocal.x, _selection.origin.y + previousLocal.y);
CGFloat dx = p.x - previous.x, dy = p.y - previous.y;
CGFloat length = hypot(dx, dy);
if (length <= 0.001) continue;
CGFloat nx = -dy / length * radius, ny = dx / length * radius;
NSBezierPath *segment = [NSBezierPath bezierPath];
[segment moveToPoint:NSMakePoint(previous.x + nx, previous.y + ny)];
[segment lineToPoint:NSMakePoint(p.x + nx, p.y + ny)];
[segment lineToPoint:NSMakePoint(p.x - nx, p.y - ny)];
[segment lineToPoint:NSMakePoint(previous.x - nx, previous.y - ny)];
[segment closePath];
[clipPath appendBezierPath:segment];
}
} else if (points.count >= 2) {
NSPoint a = [points[0] pointValue], b = [[points lastObject] pointValue];
NSRect r = NSMakeRect(_selection.origin.x + MIN(a.x, b.x), _selection.origin.y + MIN(a.y, b.y), fabs(b.x-a.x), fabs(b.y-a.y));
clipPath = [NSBezierPath bezierPathWithRect:r];
}
if (clipPath != nil) {
[clipPath addClip];
if (_mosaicPreviewImage != nil) {
NSDictionary *hints = @{NSImageHintInterpolation: @(NSImageInterpolationNone)};
[_mosaicPreviewImage drawInRect:self.bounds
fromRect:NSMakeRect(0, 0, _mosaicPreviewImage.size.width, _mosaicPreviewImage.size.height)
operation:NSCompositingOperationCopy
fraction:1.0
respectFlipped:YES
hints:hints];
} else {
// Screen capture can be unavailable before Screen Recording
// permission is granted. A checkerboard fallback still reads
// as pixelation and never resembles a marker stroke.
CGFloat tile = 8.0;
for (CGFloat y = 0; y < self.bounds.size.height; y += tile) {
for (CGFloat x = 0; x < self.bounds.size.width; x += tile) {
BOOL alternate = (((NSInteger)(x / tile) + (NSInteger)(y / tile)) % 2) == 0;
[[NSColor colorWithCalibratedWhite:(alternate ? 0.42 : 0.68) alpha:1.0] setFill];
NSRectFill(NSMakeRect(x, y, tile, tile));
}
}
}
}
[NSGraphicsContext restoreGraphicsState];
continue;
}
[color setStroke]; [color setStroke];
CGFloat strokeWidth = [self strokeWidthForItem:item]; CGFloat strokeWidth = [self strokeWidthForItem:item];
NSBezierPath *path = [NSBezierPath bezierPath]; NSBezierPath *path = [NSBezierPath bezierPath];
@@ -646,12 +761,35 @@ static id nativeOverlayKeyMonitor = nil;
} else if (points.count >= 2) { } else if (points.count >= 2) {
NSPoint a = [points[0] pointValue]; NSPoint a = [points[0] pointValue];
NSPoint b = [[points lastObject] pointValue]; NSPoint b = [[points lastObject] pointValue];
NSPoint start = NSMakePoint(_selection.origin.x + a.x, _selection.origin.y + a.y);
NSPoint end = NSMakePoint(_selection.origin.x + b.x, _selection.origin.y + b.y);
NSRect r = NSMakeRect( NSRect r = NSMakeRect(
_selection.origin.x + MIN(a.x, b.x), _selection.origin.x + MIN(a.x, b.x),
_selection.origin.y + MIN(a.y, b.y), _selection.origin.y + MIN(a.y, b.y),
fabs(b.x - a.x), fabs(b.x - a.x),
fabs(b.y - a.y)); fabs(b.y - a.y));
if ([tool isEqualToString:@"rect"]) { if ([tool isEqualToString:@"line"] || [tool isEqualToString:@"arrow"]) {
NSBezierPath *linePath = [NSBezierPath bezierPath];
[linePath moveToPoint:start];
[linePath lineToPoint:end];
[linePath setLineWidth:strokeWidth];
[linePath setLineCapStyle:NSLineCapStyleRound];
[linePath setLineJoinStyle:NSLineJoinStyleRound];
if ([tool isEqualToString:@"arrow"]) {
CGFloat dx = end.x - start.x, dy = end.y - start.y;
CGFloat length = hypot(dx, dy);
if (length >= 1.0) {
CGFloat headLength = MIN(length * 0.45, MAX(10.0, strokeWidth * 4.0));
CGFloat angle = atan2(dy, dx), spread = M_PI / 6.0;
NSPoint left = NSMakePoint(end.x - headLength * cos(angle - spread), end.y - headLength * sin(angle - spread));
NSPoint right = NSMakePoint(end.x - headLength * cos(angle + spread), end.y - headLength * sin(angle + spread));
[linePath moveToPoint:left];
[linePath lineToPoint:end];
[linePath lineToPoint:right];
}
}
[linePath stroke];
} else if ([tool isEqualToString:@"rect"]) {
NSBezierPath *rectPath = [NSBezierPath bezierPathWithRect:r]; NSBezierPath *rectPath = [NSBezierPath bezierPathWithRect:r];
[rectPath setLineWidth:strokeWidth]; [rectPath setLineWidth:strokeWidth];
[rectPath stroke]; [rectPath stroke];
@@ -700,6 +838,24 @@ static id nativeOverlayKeyMonitor = nil;
MAX(0, MIN(p.y - _selection.origin.y, _selection.size.height))); MAX(0, MIN(p.y - _selection.origin.y, _selection.size.height)));
} }
// Annotations use selection-local coordinates. When a top or left resize
// moves the selection origin, offset every local point in the opposite
// direction so the annotation remains attached to the same screen pixels.
- (void)preserveAnnotationScreenPositionsFromOrigin:(NSPoint)previousOrigin toOrigin:(NSPoint)nextOrigin {
CGFloat dx = previousOrigin.x - nextOrigin.x;
CGFloat dy = previousOrigin.y - nextOrigin.y;
if (fabs(dx) <= 0.001 && fabs(dy) <= 0.001) {
return;
}
for (NSDictionary *annotation in _annotations) {
NSMutableArray *points = (NSMutableArray *)annotation[@"points"];
for (NSUInteger i = 0; i < points.count; i++) {
NSPoint point = [points[i] pointValue];
points[i] = [NSValue valueWithPoint:NSMakePoint(point.x + dx, point.y + dy)];
}
}
}
// Translate a selection rectangle expressed in view-local coordinates into // Translate a selection rectangle expressed in view-local coordinates into
// the global top-left screen coordinate system used by // the global top-left screen coordinate system used by
// `/usr/sbin/screencapture -R`. // `/usr/sbin/screencapture -R`.
@@ -805,7 +961,7 @@ static id nativeOverlayKeyMonitor = nil;
@"tool": _activeTool, @"tool": _activeTool,
@"color": [self hexForColor:_activeColor], @"color": [self hexForColor:_activeColor],
@"nsColor": _activeColor, @"nsColor": _activeColor,
@"strokeWidth": @(_activeStrokeWidth), @"strokeWidth": @([_activeTool isEqualToString:@"mosaic-brush"] ? _mosaicBrushWidth : _activeStrokeWidth),
@"points": [NSMutableArray arrayWithObject:[NSValue valueWithPoint:local]] @"points": [NSMutableArray arrayWithObject:[NSValue valueWithPoint:local]]
} mutableCopy]; } mutableCopy];
} else if (_hasSelection && NSPointInRect(p, _selection)) { } else if (_hasSelection && NSPointInRect(p, _selection)) {
@@ -854,7 +1010,9 @@ static id nativeOverlayKeyMonitor = nil;
right = MAX(0, MIN(right, self.bounds.size.width)); right = MAX(0, MIN(right, self.bounds.size.width));
top = MAX(0, MIN(top, self.bounds.size.height)); top = MAX(0, MIN(top, self.bounds.size.height));
bottom = MAX(0, MIN(bottom, self.bounds.size.height)); bottom = MAX(0, MIN(bottom, self.bounds.size.height));
_selection = NSMakeRect(MIN(left, right), MIN(top, bottom), fabs(right - left), fabs(bottom - top)); NSRect nextSelection = NSMakeRect(MIN(left, right), MIN(top, bottom), fabs(right - left), fabs(bottom - top));
[self preserveAnnotationScreenPositionsFromOrigin:_selection.origin toOrigin:nextSelection.origin];
_selection = nextSelection;
} else if (_draggingTextAnnotation && _selectedTextAnnotationIndex >= 0 && _selectedTextAnnotationIndex < (NSInteger)_annotations.count) { } else if (_draggingTextAnnotation && _selectedTextAnnotationIndex >= 0 && _selectedTextAnnotationIndex < (NSInteger)_annotations.count) {
NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_selectedTextAnnotationIndex]; NSMutableDictionary *item = (NSMutableDictionary *)_annotations[(NSUInteger)_selectedTextAnnotationIndex];
NSMutableArray *points = (NSMutableArray *)item[@"points"]; NSMutableArray *points = (NSMutableArray *)item[@"points"];
@@ -869,7 +1027,7 @@ static id nativeOverlayKeyMonitor = nil;
} else if (_annotating && _draftAnnotation != nil) { } else if (_annotating && _draftAnnotation != nil) {
NSMutableArray *points = _draftAnnotation[@"points"]; NSMutableArray *points = _draftAnnotation[@"points"];
NSPoint local = [self localPoint:p]; NSPoint local = [self localPoint:p];
if ([_activeTool isEqualToString:@"pen"]) { if ([_activeTool isEqualToString:@"pen"] || [_activeTool isEqualToString:@"mosaic-brush"]) {
[points addObject:[NSValue valueWithPoint:local]]; [points addObject:[NSValue valueWithPoint:local]];
} else { } else {
if (points.count == 1) { if (points.count == 1) {
@@ -936,9 +1094,12 @@ static id nativeOverlayKeyMonitor = nil;
[_uploadButton setHidden:!visible]; [_uploadButton setHidden:!visible];
[_actionToolbarBg setHidden:!visible]; [_actionToolbarBg setHidden:!visible];
[_penButton setHidden:!visible]; [_penButton setHidden:!visible];
[_lineButton setHidden:!visible];
[_arrowButton setHidden:!visible];
[_rectButton setHidden:!visible]; [_rectButton setHidden:!visible];
[_ellipseButton setHidden:!visible]; [_ellipseButton setHidden:!visible];
[_textButton setHidden:!visible]; [_textButton setHidden:!visible];
[_mosaicButton setHidden:!visible];
[_undoButton setHidden:!visible]; [_undoButton setHidden:!visible];
[_toolSettingsView setHidden:!visible || _activeTool == nil]; [_toolSettingsView setHidden:!visible || _activeTool == nil];
[_sizeLabel setHidden:!visible]; [_sizeLabel setHidden:!visible];
@@ -981,20 +1142,25 @@ static id nativeOverlayKeyMonitor = nil;
// Mark toolbar sits to the LEFT of the action toolbar (same row) with an // Mark toolbar sits to the LEFT of the action toolbar (same row) with an
// 8px gap between the two groups, so they never overlap on tiny selections. // 8px gap between the two groups, so they never overlap on tiny selections.
CGFloat markW = 178; CGFloat markW = 280;
CGFloat markGap = 8; CGFloat markGap = 8;
CGFloat markX = MAX(0, x - markGap - markW); CGFloat markX = MAX(0, x - markGap - markW);
[_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)]; [_penButton setFrame:NSMakeRect(markX + 4, y + 4, 28, 28)];
[_rectButton setFrame:NSMakeRect(markX + 38, y + 4, 28, 28)]; [_lineButton setFrame:NSMakeRect(markX + 38, y + 4, 28, 28)];
[_ellipseButton setFrame:NSMakeRect(markX + 72, y + 4, 28, 28)]; [_arrowButton setFrame:NSMakeRect(markX + 72, y + 4, 28, 28)];
[_textButton setFrame:NSMakeRect(markX + 106, y + 4, 28, 28)]; [_rectButton setFrame:NSMakeRect(markX + 106, y + 4, 28, 28)];
[_undoButton setFrame:NSMakeRect(markX + 144, y + 4, 28, 28)]; [_ellipseButton setFrame:NSMakeRect(markX + 140, y + 4, 28, 28)];
[_textButton setFrame:NSMakeRect(markX + 174, y + 4, 28, 28)];
[_mosaicButton setFrame:NSMakeRect(markX + 208, y + 4, 28, 28)];
[_undoButton setFrame:NSMakeRect(markX + 246, y + 4, 28, 28)];
[_undoButton setEnabled:YES]; [_undoButton setEnabled:YES];
[[_undoButton layer] setOpacity:_annotations.count > 0 ? 1.0 : 0.35]; [[_undoButton layer] setOpacity:_annotations.count > 0 ? 1.0 : 0.35];
if (_activeTool != nil) { if (_activeTool != nil) {
BOOL isText = [_activeTool isEqualToString:@"text"]; BOOL isText = [_activeTool isEqualToString:@"text"];
CGFloat settingsW = isText ? 462 : 398; BOOL isMosaic = [_activeTool hasPrefix:@"mosaic-"];
BOOL mosaicBrush = [_mosaicMode isEqualToString:@"brush"];
CGFloat settingsW = isMosaic ? (mosaicBrush ? 304 : 112) : (isText ? 462 : 398);
CGFloat settingsH = 58; CGFloat settingsH = 58;
CGFloat settingsX = MAX(8, MIN(markX, self.bounds.size.width - settingsW - 8)); CGFloat settingsX = MAX(8, MIN(markX, self.bounds.size.width - settingsW - 8));
CGFloat settingsY = y + toolbarH + 4; CGFloat settingsY = y + toolbarH + 4;
@@ -1002,9 +1168,12 @@ static id nativeOverlayKeyMonitor = nil;
settingsY = y - settingsH - 8; settingsY = y - settingsH - 8;
} }
CGFloat activeCenter = markX + 18; CGFloat activeCenter = markX + 18;
if ([_activeTool isEqualToString:@"rect"]) activeCenter = markX + 52; if ([_activeTool isEqualToString:@"line"]) activeCenter = markX + 52;
if ([_activeTool isEqualToString:@"ellipse"]) activeCenter = markX + 86; if ([_activeTool isEqualToString:@"arrow"]) activeCenter = markX + 86;
if ([_activeTool isEqualToString:@"text"]) activeCenter = markX + 120; if ([_activeTool isEqualToString:@"rect"]) activeCenter = markX + 120;
if ([_activeTool isEqualToString:@"ellipse"]) activeCenter = markX + 154;
if ([_activeTool isEqualToString:@"text"]) activeCenter = markX + 188;
if (isMosaic) activeCenter = markX + 222;
[_toolSettingsView setFrame:NSMakeRect(settingsX, settingsY, settingsW, settingsH)]; [_toolSettingsView setFrame:NSMakeRect(settingsX, settingsY, settingsW, settingsH)];
[_toolSettingsView setArrowX:MAX(18, MIN(activeCenter - settingsX, settingsW - 18))]; [_toolSettingsView setArrowX:MAX(18, MIN(activeCenter - settingsX, settingsW - 18))];
[_toolSettingsView setNeedsDisplay:YES]; [_toolSettingsView setNeedsDisplay:YES];
@@ -1012,21 +1181,34 @@ static id nativeOverlayKeyMonitor = nil;
CGFloat contentY = 18; CGFloat contentY = 18;
CGFloat xCursor = 14; CGFloat xCursor = 14;
for (NSButton *button in _strokeSettingButtons) { for (NSButton *button in _strokeSettingButtons) {
[button setHidden:isText]; [button setHidden:isText || isMosaic];
[button setFrame:NSMakeRect(xCursor, contentY, 32, 32)]; [button setFrame:NSMakeRect(xCursor, contentY, 32, 32)];
xCursor += 42; xCursor += 42;
} }
xCursor = 14; xCursor = 14;
for (NSButton *button in _fontSettingButtons) { for (NSButton *button in _fontSettingButtons) {
[button setHidden:!isText]; [button setHidden:!isText || isMosaic];
[button setFrame:NSMakeRect(xCursor, contentY, 40, 32)]; [button setFrame:NSMakeRect(xCursor, contentY, 40, 32)];
xCursor += 48; xCursor += 48;
} }
for (NSUInteger i = 0; i < _mosaicModeButtons.count; i++) {
NSButton *button = _mosaicModeButtons[i];
[button setHidden:!isMosaic];
[button setFrame:NSMakeRect(14 + i * 38, contentY + 1, 36, 30)];
}
for (NSUInteger i = 0; i < _mosaicWidthButtons.count; i++) {
NSButton *button = _mosaicWidthButtons[i];
[button setHidden:!isMosaic || !mosaicBrush];
[button setFrame:NSMakeRect(122 + i * 42, contentY, 32, 32)];
}
CGFloat groupW = isText ? (4 * 40 + 3 * 8) : (3 * 32 + 2 * 10); CGFloat groupW = isText ? (4 * 40 + 3 * 8) : (3 * 32 + 2 * 10);
CGFloat dividerX = 14 + groupW + 12; CGFloat dividerX = 14 + groupW + 12;
[_settingsDivider setHidden:isMosaic && !mosaicBrush];
if (isMosaic) dividerX = 102;
[_settingsDivider setFrame:NSMakeRect(dividerX, contentY + 3, 1, 26)]; [_settingsDivider setFrame:NSMakeRect(dividerX, contentY + 3, 1, 26)];
CGFloat colorX = dividerX + 18; CGFloat colorX = dividerX + 18;
for (NSButton *button in _colorSettingButtons) { for (NSButton *button in _colorSettingButtons) {
[button setHidden:isMosaic];
[button setFrame:NSMakeRect(colorX, contentY + 5, 22, 22)]; [button setFrame:NSMakeRect(colorX, contentY + 5, 22, 22)];
colorX += 34; colorX += 34;
} }
@@ -1035,7 +1217,7 @@ static id nativeOverlayKeyMonitor = nil;
} }
- (void)updateToolButtonStates { - (void)updateToolButtonStates {
NSDictionary *buttons = @{@"pen": _penButton, @"rect": _rectButton, @"ellipse": _ellipseButton, @"text": _textButton}; NSDictionary *buttons = @{@"pen": _penButton, @"line": _lineButton, @"arrow": _arrowButton, @"rect": _rectButton, @"ellipse": _ellipseButton, @"text": _textButton};
for (NSString *tool in buttons) { for (NSString *tool in buttons) {
NSButton *button = buttons[tool]; NSButton *button = buttons[tool];
NSColor *bg = [tool isEqualToString:_activeTool] NSColor *bg = [tool isEqualToString:_activeTool]
@@ -1043,6 +1225,8 @@ static id nativeOverlayKeyMonitor = nil;
: [NSColor clearColor]; : [NSColor clearColor];
[[button layer] setBackgroundColor:[bg CGColor]]; [[button layer] setBackgroundColor:[bg CGColor]];
} }
BOOL mosaicActive = [_activeTool hasPrefix:@"mosaic-"];
[[_mosaicButton layer] setBackgroundColor:[(mosaicActive ? [NSColor colorWithCalibratedWhite:1.0 alpha:0.14] : [NSColor clearColor]) CGColor]];
[self updateToolSettingsStates]; [self updateToolSettingsStates];
} }
@@ -1070,6 +1254,23 @@ static id nativeOverlayKeyMonitor = nil;
[button setAttributedTitle:title]; [button setAttributedTitle:title];
} }
} }
for (NSButton *button in _mosaicModeButtons) {
BOOL active = (button.tag == 0 && [_mosaicMode isEqualToString:@"brush"]) || (button.tag == 1 && [_mosaicMode isEqualToString:@"rect"]);
[self styleButton:button background:(active ? [NSColor whiteColor] : clear) foreground:(active ? activeFg : normalFg)];
if (@available(macOS 10.14, *)) {
[button setContentTintColor:(active ? activeFg : normalFg)];
}
[button setAccessibilityValue:(active ? @"已选择" : @"未选择")];
}
for (NSButton *button in _mosaicWidthButtons) {
BOOL active = fabs((CGFloat)button.tag - _mosaicBrushWidth) < 0.1;
[[button layer] setBackgroundColor:[(active ? activeBg : clear) CGColor]];
NSMutableAttributedString *title = [[button attributedTitle] mutableCopy];
if (title.length > 0) {
[title addAttribute:NSForegroundColorAttributeName value:(active ? activeFg : normalFg) range:NSMakeRange(0, title.length)];
[button setAttributedTitle:title];
}
}
NSString *activeHex = [self hexForColor:_activeColor]; NSString *activeHex = [self hexForColor:_activeColor];
NSArray *colors = objc_getAssociatedObject(_toolSettingsView, "snapgoColors"); NSArray *colors = objc_getAssociatedObject(_toolSettingsView, "snapgoColors");
@@ -1087,9 +1288,12 @@ static id nativeOverlayKeyMonitor = nil;
[self syncControls]; [self syncControls];
} }
- (void)selectPen { [self selectTool:@"pen"]; } - (void)selectPen { [self selectTool:@"pen"]; }
- (void)selectLine { [self selectTool:@"line"]; }
- (void)selectArrow { [self selectTool:@"arrow"]; }
- (void)selectRect { [self selectTool:@"rect"]; } - (void)selectRect { [self selectTool:@"rect"]; }
- (void)selectEllipse { [self selectTool:@"ellipse"]; } - (void)selectEllipse { [self selectTool:@"ellipse"]; }
- (void)selectText { [self selectTool:@"text"]; } - (void)selectText { [self selectTool:@"text"]; }
- (void)selectMosaic { [self selectTool:[_mosaicMode isEqualToString:@"brush"] ? @"mosaic-brush" : @"mosaic-rect"]; }
- (void)undoAnnotation { - (void)undoAnnotation {
if (_annotations.count == 0 && _textEditor == nil) { if (_annotations.count == 0 && _textEditor == nil) {
return; return;
@@ -1228,6 +1432,16 @@ static id nativeOverlayKeyMonitor = nil;
[self updateToolSettingsStates]; [self updateToolSettingsStates];
} }
- (void)selectMosaicMode:(NSButton *)sender {
_mosaicMode = sender.tag == 0 ? @"brush" : @"rect";
[self selectMosaic];
}
- (void)selectMosaicWidth:(NSButton *)sender {
_mosaicBrushWidth = MAX(8.0, (CGFloat)sender.tag);
[self updateToolSettingsStates];
}
- (void)selectFontSize:(NSButton *)sender { - (void)selectFontSize:(NSButton *)sender {
_activeFontSize = MAX(8.0, (CGFloat)sender.tag); _activeFontSize = MAX(8.0, (CGFloat)sender.tag);
if (_textEditor != nil) { if (_textEditor != nil) {
@@ -1421,6 +1635,60 @@ static NSScreen *snipScreenContainingCursor(void) {
return [[NSScreen screens] firstObject]; return [[NSScreen screens] firstObject];
} }
// snipMosaicPreviewImage captures the target display before the transparent
// overlay becomes visible, then downsamples it to one sample per 10 logical
// points. The view scales this tiny image back with nearest-neighbour
// interpolation inside each mosaic mask, yielding stable, screen-derived
// pixel blocks while the pointer is moving.
static NSImage *snipMosaicPreviewImage(NSScreen *screen) {
NSSize fullSize = [screen frame].size;
NSUInteger screenIndex = [[NSScreen screens] indexOfObjectIdenticalTo:screen];
if (screenIndex == NSNotFound) screenIndex = 0;
NSString *temporaryPath = [NSTemporaryDirectory() stringByAppendingPathComponent:
[NSString stringWithFormat:@"snapgo-mosaic-%@.png", [[NSUUID UUID] UUIDString]]];
NSTask *captureTask = [[NSTask alloc] init];
[captureTask setExecutableURL:[NSURL fileURLWithPath:@"/usr/sbin/screencapture"]];
[captureTask setArguments:@[@"-x", @"-D", [NSString stringWithFormat:@"%lu", (unsigned long)screenIndex + 1], temporaryPath]];
NSError *launchError = nil;
if (![captureTask launchAndReturnError:&launchError]) return nil;
[captureTask waitUntilExit];
NSImage *fullImage = [[NSImage alloc] initWithContentsOfFile:temporaryPath];
[[NSFileManager defaultManager] removeItemAtPath:temporaryPath error:nil];
if ([captureTask terminationStatus] != 0 || fullImage == nil) return nil;
NSInteger pixelW = MAX(1, (NSInteger)ceil(fullSize.width / 10.0));
NSInteger pixelH = MAX(1, (NSInteger)ceil(fullSize.height / 10.0));
NSBitmapImageRep *bitmap = [[NSBitmapImageRep alloc]
initWithBitmapDataPlanes:NULL
pixelsWide:pixelW
pixelsHigh:pixelH
bitsPerSample:8
samplesPerPixel:4
hasAlpha:YES
isPlanar:NO
colorSpaceName:NSCalibratedRGBColorSpace
bytesPerRow:0
bitsPerPixel:0];
if (bitmap == nil) return nil;
NSGraphicsContext *context = [NSGraphicsContext graphicsContextWithBitmapImageRep:bitmap];
[NSGraphicsContext saveGraphicsState];
[NSGraphicsContext setCurrentContext:context];
[context setImageInterpolation:NSImageInterpolationHigh];
[fullImage drawInRect:NSMakeRect(0, 0, pixelW, pixelH)
fromRect:NSMakeRect(0, 0, fullSize.width, fullSize.height)
operation:NSCompositingOperationCopy
fraction:1.0
respectFlipped:NO
hints:nil];
[context flushGraphics];
[NSGraphicsContext restoreGraphicsState];
NSImage *result = [[NSImage alloc] initWithSize:NSMakeSize(pixelW, pixelH)];
[result addRepresentation:bitmap];
return result;
}
static void snipShowNativeOverlay(int width, int height) { static void snipShowNativeOverlay(int width, int height) {
dispatch_async(dispatch_get_main_queue(), ^{ dispatch_async(dispatch_get_main_queue(), ^{
NSScreen *screen = snipScreenContainingCursor(); NSScreen *screen = snipScreenContainingCursor();
@@ -1430,6 +1698,7 @@ static void snipShowNativeOverlay(int width, int height) {
} }
NSRect frame = [screen frame]; NSRect frame = [screen frame];
NSImage *mosaicPreview = snipMosaicPreviewImage(screen);
[NSApp activateIgnoringOtherApps:YES]; [NSApp activateIgnoringOtherApps:YES];
nativeOverlayWindow = [[SnipNativeOverlayPanel alloc] nativeOverlayWindow = [[SnipNativeOverlayPanel alloc]
@@ -1450,6 +1719,7 @@ static void snipShowNativeOverlay(int width, int height) {
SnipNativeOverlayView *view = [[SnipNativeOverlayView alloc] SnipNativeOverlayView *view = [[SnipNativeOverlayView alloc]
initWithFrame:NSMakeRect(0, 0, frame.size.width, frame.size.height)]; initWithFrame:NSMakeRect(0, 0, frame.size.width, frame.size.height)];
[view setMosaicPreviewImage:mosaicPreview];
[nativeOverlayWindow setContentView:view]; [nativeOverlayWindow setContentView:view];
[nativeOverlayWindow makeKeyAndOrderFront:nil]; [nativeOverlayWindow makeKeyAndOrderFront:nil];
[nativeOverlayWindow makeFirstResponder:view]; [nativeOverlayWindow makeFirstResponder:view];