2 Commits

Author SHA1 Message Date
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
13 changed files with 226 additions and 32 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
}
}
}
+6 -1
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
@@ -539,12 +540,16 @@ 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) {
+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"];
+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) {
+21 -1
View File
@@ -838,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`.
@@ -992,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"];