feat(app): support local screenshot actions
This commit is contained in:
@@ -88,6 +88,13 @@ type CaptureResult struct {
|
|||||||
Annotations []application.Annotation `json:"annotations"`
|
Annotations []application.Annotation `json:"annotations"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CaptureActionResult tells the frontend whether an action was completed or
|
||||||
|
// the user cancelled a secondary choice such as the save destination dialog.
|
||||||
|
type CaptureActionResult struct {
|
||||||
|
Completed bool `json:"completed"`
|
||||||
|
Path string `json:"path,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
// NewApp creates a new App with collaborators already initialised.
|
// NewApp creates a new App with collaborators already initialised.
|
||||||
func NewApp() *App {
|
func NewApp() *App {
|
||||||
store, err := config.NewFileStore()
|
store, err := config.NewFileStore()
|
||||||
@@ -169,17 +176,13 @@ func (a *App) runInteractiveCapture() {
|
|||||||
cfg := a.cfg
|
cfg := a.cfg
|
||||||
a.mu.RUnlock()
|
a.mu.RUnlock()
|
||||||
|
|
||||||
if !cfg.IsS3Configured() {
|
var provider domain.OSSProvider
|
||||||
a.surfaceWindow()
|
if cfg.IsS3Configured() {
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure",
|
var err error
|
||||||
"S3 is not configured yet — open settings first")
|
provider, err = oss.NewS3Provider(cfg.S3)
|
||||||
return
|
if err != nil {
|
||||||
}
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
provider, err := oss.NewS3Provider(cfg.S3)
|
}
|
||||||
if err != nil {
|
|
||||||
a.surfaceWindow()
|
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
|
||||||
return
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Hide the settings window before transforming it into an overlay, so
|
// Hide the settings window before transforming it into an overlay, so
|
||||||
@@ -296,6 +299,58 @@ func (a *App) runUploadPipeline(provider domain.OSSProvider, pngBytes []byte) er
|
|||||||
return svc.ExecuteWithBytes(a.ctx, pngBytes)
|
return svc.ExecuteWithBytes(a.ctx, pngBytes)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) runCopyImagePipeline(pngBytes []byte) error {
|
||||||
|
svc := &application.CaptureActionsService{
|
||||||
|
Clipboard: a.clip,
|
||||||
|
Notifier: &runtimeNotifier{ctx: a.ctx},
|
||||||
|
}
|
||||||
|
return svc.CopyImage(a.ctx, pngBytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) runSaveImagePipeline(pngBytes []byte, dir string) (string, error) {
|
||||||
|
svc := &application.CaptureActionsService{
|
||||||
|
Clipboard: a.clip,
|
||||||
|
Notifier: &runtimeNotifier{ctx: a.ctx},
|
||||||
|
}
|
||||||
|
return svc.SaveImage(a.ctx, pngBytes, dir)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) consumePendingCapture() (*pendingCapture, error) {
|
||||||
|
a.pendingMu.Lock()
|
||||||
|
pc := a.pending
|
||||||
|
a.pending = nil
|
||||||
|
a.pendingMu.Unlock()
|
||||||
|
|
||||||
|
if pc == nil {
|
||||||
|
return nil, fmt.Errorf("no pending capture")
|
||||||
|
}
|
||||||
|
return pc, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) captureSelectedPNG(result CaptureResult, pc *pendingCapture) ([]byte, error) {
|
||||||
|
rect := result.Rect
|
||||||
|
captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
|
||||||
|
cropped, err := a.capturer.CaptureRegion(captureRect)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(result.Annotations) > 0 {
|
||||||
|
cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return cropped, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) chooseSaveDirectory() (string, error) {
|
||||||
|
return wruntime.OpenDirectoryDialog(a.ctx, wruntime.OpenDialogOptions{
|
||||||
|
Title: "Save screenshot to folder",
|
||||||
|
DefaultDirectory: userPicturesDir(),
|
||||||
|
CanCreateDirectories: true,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Bound methods (called from the frontend via Wails)
|
// Bound methods (called from the frontend via Wails)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -366,77 +421,189 @@ func (a *App) CaptureNow() {
|
|||||||
// keep showing the screenshot (e.g. for a retry) — currently it just
|
// keep showing the screenshot (e.g. for a retry) — currently it just
|
||||||
// dismisses regardless and surfaces the error via the upload:failure toast.
|
// dismisses regardless and surfaces the error via the upload:failure toast.
|
||||||
func (a *App) ConfirmRegion(result CaptureResult) error {
|
func (a *App) ConfirmRegion(result CaptureResult) error {
|
||||||
a.pendingMu.Lock()
|
pc, err := a.consumePendingCapture()
|
||||||
pc := a.pending
|
if err != nil {
|
||||||
a.pending = nil
|
return err
|
||||||
a.pendingMu.Unlock()
|
|
||||||
|
|
||||||
if pc == nil {
|
|
||||||
return fmt.Errorf("no pending capture")
|
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
a.capturing.Store(false)
|
a.capturing.Store(false)
|
||||||
a.dismissOverlay()
|
a.dismissOverlay()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
rect := result.Rect
|
|
||||||
captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
|
|
||||||
a.dismissOverlay()
|
a.dismissOverlay()
|
||||||
flushFrame()
|
flushFrame()
|
||||||
|
|
||||||
cropped, err := a.capturer.CaptureRegion(captureRect)
|
cropped, err := a.captureSelectedPNG(result, pc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(result.Annotations) > 0 {
|
|
||||||
cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale)
|
|
||||||
if err != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := a.runUploadPipeline(pc.Provider, cropped); err != nil {
|
if err := a.runUploadPipeline(pc.Provider, cropped); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// CopyRegionImage is invoked by the fallback Wails overlay when the user
|
||||||
|
// double-clicks the selected area.
|
||||||
|
func (a *App) CopyRegionImage(result CaptureResult) error {
|
||||||
|
pc, err := a.consumePendingCapture()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
a.capturing.Store(false)
|
||||||
|
a.dismissOverlay()
|
||||||
|
}()
|
||||||
|
|
||||||
|
a.dismissOverlay()
|
||||||
|
flushFrame()
|
||||||
|
|
||||||
|
cropped, err := a.captureSelectedPNG(result, pc)
|
||||||
|
if err != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.runCopyImagePipeline(cropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SaveRegionImage lets the fallback Wails overlay pick a native destination
|
||||||
|
// folder and writes the selected screenshot PNG there.
|
||||||
|
func (a *App) SaveRegionImage(result CaptureResult) (CaptureActionResult, error) {
|
||||||
|
pc, err := a.consumePendingCapture()
|
||||||
|
if err != nil {
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
a.dismissOverlay()
|
||||||
|
flushFrame()
|
||||||
|
|
||||||
|
cropped, err := a.captureSelectedPNG(result, pc)
|
||||||
|
if err != nil {
|
||||||
|
a.capturing.Store(false)
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
dir, err := a.chooseSaveDirectory()
|
||||||
|
if err != nil {
|
||||||
|
a.capturing.Store(false)
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
a.capturing.Store(false)
|
||||||
|
a.dismissOverlay()
|
||||||
|
return CaptureActionResult{Completed: false}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
path, err := a.runSaveImagePipeline(cropped, dir)
|
||||||
|
a.capturing.Store(false)
|
||||||
|
a.dismissOverlay()
|
||||||
|
if err != nil {
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
return CaptureActionResult{Completed: true, Path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
// ConfirmNativeRegion mirrors ConfirmRegion for the macOS native overlay.
|
// ConfirmNativeRegion mirrors ConfirmRegion for the macOS native overlay.
|
||||||
// The native AppKit panel has already been closed by the time this method is
|
// The native AppKit panel has already been closed by the time this method is
|
||||||
// called, so we must not dismiss/restore the Wails overlay window here.
|
// called, so we must not dismiss/restore the Wails overlay window here.
|
||||||
func (a *App) ConfirmNativeRegion(result CaptureResult) error {
|
func (a *App) ConfirmNativeRegion(result CaptureResult) error {
|
||||||
a.pendingMu.Lock()
|
pc, err := a.consumePendingCapture()
|
||||||
pc := a.pending
|
if err != nil {
|
||||||
a.pending = nil
|
return err
|
||||||
a.pendingMu.Unlock()
|
|
||||||
|
|
||||||
if pc == nil {
|
|
||||||
return fmt.Errorf("no pending capture")
|
|
||||||
}
|
}
|
||||||
defer func() {
|
defer func() {
|
||||||
a.capturing.Store(false)
|
a.capturing.Store(false)
|
||||||
hideDockIcon()
|
hideDockIcon()
|
||||||
}()
|
}()
|
||||||
|
|
||||||
rect := result.Rect
|
|
||||||
captureRect := image.Rect(rect.X, rect.Y, rect.X+rect.W, rect.Y+rect.H)
|
|
||||||
flushFrame()
|
flushFrame()
|
||||||
cropped, err := a.capturer.CaptureRegion(captureRect)
|
cropped, err := a.captureSelectedPNG(result, pc)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if len(result.Annotations) > 0 {
|
|
||||||
cropped, err = application.ApplyAnnotations(cropped, result.Annotations, pc.Display.Scale)
|
|
||||||
if err != nil {
|
|
||||||
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return a.runUploadPipeline(pc.Provider, cropped)
|
return a.runUploadPipeline(pc.Provider, cropped)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) CopyNativeRegionImage(result CaptureResult) error {
|
||||||
|
pc, err := a.consumePendingCapture()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
a.capturing.Store(false)
|
||||||
|
hideDockIcon()
|
||||||
|
}()
|
||||||
|
|
||||||
|
flushFrame()
|
||||||
|
cropped, err := a.captureSelectedPNG(result, pc)
|
||||||
|
if err != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return a.runCopyImagePipeline(cropped)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveNativeRegionImage(result CaptureResult) (CaptureActionResult, error) {
|
||||||
|
pc, err := a.consumePendingCapture()
|
||||||
|
if err != nil {
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
a.capturing.Store(false)
|
||||||
|
hideDockIcon()
|
||||||
|
}()
|
||||||
|
|
||||||
|
flushFrame()
|
||||||
|
cropped, err := a.captureSelectedPNG(result, pc)
|
||||||
|
if err != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
dir, err := a.chooseSaveDirectory()
|
||||||
|
if err != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
if dir == "" {
|
||||||
|
return CaptureActionResult{Completed: false}, nil
|
||||||
|
}
|
||||||
|
path, err := a.runSaveImagePipeline(cropped, dir)
|
||||||
|
if err != nil {
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
return CaptureActionResult{Completed: true, Path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) SaveNativeRegionImageToDir(result CaptureResult, dir string) (CaptureActionResult, error) {
|
||||||
|
pc, err := a.consumePendingCapture()
|
||||||
|
if err != nil {
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
a.capturing.Store(false)
|
||||||
|
hideDockIcon()
|
||||||
|
}()
|
||||||
|
|
||||||
|
if dir == "" {
|
||||||
|
return CaptureActionResult{Completed: false}, nil
|
||||||
|
}
|
||||||
|
flushFrame()
|
||||||
|
cropped, err := a.captureSelectedPNG(result, pc)
|
||||||
|
if err != nil {
|
||||||
|
wruntime.EventsEmit(a.ctx, "upload:failure", err.Error())
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
path, err := a.runSaveImagePipeline(cropped, dir)
|
||||||
|
if err != nil {
|
||||||
|
return CaptureActionResult{}, err
|
||||||
|
}
|
||||||
|
return CaptureActionResult{Completed: true, Path: path}, nil
|
||||||
|
}
|
||||||
|
|
||||||
func parseNativeAnnotations(raw string) []application.Annotation {
|
func parseNativeAnnotations(raw string) []application.Annotation {
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -21,6 +21,8 @@ import { EventsOn, EventsOff } from '../wailsjs/runtime/runtime'
|
|||||||
import {
|
import {
|
||||||
RetryRegisterHotkey,
|
RetryRegisterHotkey,
|
||||||
ConfirmRegion,
|
ConfirmRegion,
|
||||||
|
CopyRegionImage,
|
||||||
|
SaveRegionImage,
|
||||||
CancelRegion,
|
CancelRegion,
|
||||||
} from '../wailsjs/go/main/App'
|
} from '../wailsjs/go/main/App'
|
||||||
|
|
||||||
@@ -85,6 +87,50 @@ async function onOverlayConfirm(rect: {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function onOverlayCopy(rect: {
|
||||||
|
rect: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
annotations: Array<{
|
||||||
|
tool: string
|
||||||
|
color: string
|
||||||
|
points: Array<{ x: number; y: number }>
|
||||||
|
}>
|
||||||
|
}) {
|
||||||
|
mode.value = 'settings'
|
||||||
|
overlayPayload.value = null
|
||||||
|
try {
|
||||||
|
await CopyRegionImage(rect as any)
|
||||||
|
} catch {
|
||||||
|
/* Surfaced via upload:failure */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onOverlaySave(rect: {
|
||||||
|
rect: {
|
||||||
|
x: number
|
||||||
|
y: number
|
||||||
|
w: number
|
||||||
|
h: number
|
||||||
|
}
|
||||||
|
annotations: Array<{
|
||||||
|
tool: string
|
||||||
|
color: string
|
||||||
|
points: Array<{ x: number; y: number }>
|
||||||
|
}>
|
||||||
|
}) {
|
||||||
|
mode.value = 'settings'
|
||||||
|
overlayPayload.value = null
|
||||||
|
try {
|
||||||
|
await SaveRegionImage(rect as any)
|
||||||
|
} catch {
|
||||||
|
/* Surfaced via upload:failure */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function onOverlayCancel() {
|
async function onOverlayCancel() {
|
||||||
mode.value = 'settings'
|
mode.value = 'settings'
|
||||||
overlayPayload.value = null
|
overlayPayload.value = null
|
||||||
@@ -143,6 +189,8 @@ onUnmounted(() => {
|
|||||||
:width="overlayPayload.cssWidth"
|
:width="overlayPayload.cssWidth"
|
||||||
:height="overlayPayload.cssHeight"
|
:height="overlayPayload.cssHeight"
|
||||||
@confirm="onOverlayConfirm"
|
@confirm="onOverlayConfirm"
|
||||||
|
@copy="onOverlayCopy"
|
||||||
|
@save="onOverlaySave"
|
||||||
@cancel="onOverlayCancel"
|
@cancel="onOverlayCancel"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
|||||||
@@ -30,6 +30,14 @@ const emit = defineEmits<{
|
|||||||
e: 'confirm',
|
e: 'confirm',
|
||||||
payload: { rect: Rect; annotations: Annotation[] }
|
payload: { rect: Rect; annotations: Annotation[] }
|
||||||
): void
|
): void
|
||||||
|
(
|
||||||
|
e: 'copy',
|
||||||
|
payload: { rect: Rect; annotations: Annotation[] }
|
||||||
|
): void
|
||||||
|
(
|
||||||
|
e: 'save',
|
||||||
|
payload: { rect: Rect; annotations: Annotation[] }
|
||||||
|
): void
|
||||||
(e: 'cancel'): void
|
(e: 'cancel'): void
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
@@ -92,7 +100,7 @@ const sizeLabel = computed(() => {
|
|||||||
|
|
||||||
const rightToolbarPos = computed(() => {
|
const rightToolbarPos = computed(() => {
|
||||||
if (!rect.value) return null
|
if (!rect.value) return null
|
||||||
return placeToolbar(rect.value, 220, 40, 'right')
|
return placeToolbar(rect.value, 304, 40, 'right')
|
||||||
})
|
})
|
||||||
|
|
||||||
const leftToolbarPos = computed(() => {
|
const leftToolbarPos = computed(() => {
|
||||||
@@ -218,6 +226,11 @@ function onSelectionMouseDown(e: MouseEvent) {
|
|||||||
if (e.button !== 0 || !rect.value) return
|
if (e.button !== 0 || !rect.value) return
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
paletteOpen.value = false
|
paletteOpen.value = false
|
||||||
|
if (e.detail >= 2) {
|
||||||
|
removeSinglePointAnnotation()
|
||||||
|
onCopy()
|
||||||
|
return
|
||||||
|
}
|
||||||
const p = pointFromEvent(e)
|
const p = pointFromEvent(e)
|
||||||
const handle = hitHandle(p)
|
const handle = hitHandle(p)
|
||||||
if (handle) {
|
if (handle) {
|
||||||
@@ -312,11 +325,26 @@ function chooseColor(color: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onConfirm() {
|
function onConfirm() {
|
||||||
|
emitAction('confirm')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onCopy() {
|
||||||
|
emitAction('copy')
|
||||||
|
}
|
||||||
|
|
||||||
|
function onSave() {
|
||||||
|
emitAction('save')
|
||||||
|
}
|
||||||
|
|
||||||
|
function emitAction(action: 'confirm' | 'copy' | 'save') {
|
||||||
if (!rect.value) return
|
if (!rect.value) return
|
||||||
emit('confirm', {
|
const payload = {
|
||||||
rect: { ...rect.value },
|
rect: { ...rect.value },
|
||||||
annotations: annotations.value,
|
annotations: annotations.value,
|
||||||
})
|
}
|
||||||
|
if (action === 'confirm') emit('confirm', payload)
|
||||||
|
if (action === 'copy') emit('copy', payload)
|
||||||
|
if (action === 'save') emit('save', payload)
|
||||||
}
|
}
|
||||||
|
|
||||||
function onCancel() {
|
function onCancel() {
|
||||||
@@ -337,6 +365,13 @@ function undoAnnotation() {
|
|||||||
annotations.value.pop()
|
annotations.value.pop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function removeSinglePointAnnotation() {
|
||||||
|
const last = annotations.value[annotations.value.length - 1]
|
||||||
|
if (last && last.tool === activeTool.value && last.points.length <= 1) {
|
||||||
|
annotations.value.pop()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
window.addEventListener('keydown', onKeydown)
|
window.addEventListener('keydown', onKeydown)
|
||||||
})
|
})
|
||||||
@@ -522,6 +557,9 @@ onUnmounted(() => {
|
|||||||
@mousedown.stop
|
@mousedown.stop
|
||||||
>
|
>
|
||||||
<button class="btn cancel" @click="onCancel" title="Esc">Cancel</button>
|
<button class="btn cancel" @click="onCancel" title="Esc">Cancel</button>
|
||||||
|
<button class="btn secondary" @click="onSave" title="Save to folder">
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
<button class="btn primary" @click="onConfirm" title="Enter">
|
<button class="btn primary" @click="onConfirm" title="Enter">
|
||||||
Upload & copy
|
Upload & copy
|
||||||
</button>
|
</button>
|
||||||
@@ -612,7 +650,7 @@ onUnmounted(() => {
|
|||||||
width: 190px;
|
width: 190px;
|
||||||
}
|
}
|
||||||
.action-toolbar {
|
.action-toolbar {
|
||||||
width: 220px;
|
width: 304px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn,
|
.btn,
|
||||||
@@ -636,6 +674,13 @@ onUnmounted(() => {
|
|||||||
.btn.cancel:hover {
|
.btn.cancel:hover {
|
||||||
background: rgba(255, 255, 255, 0.08);
|
background: rgba(255, 255, 255, 0.08);
|
||||||
}
|
}
|
||||||
|
.btn.secondary {
|
||||||
|
background: rgba(255, 255, 255, 0.12);
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
.btn.secondary:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.18);
|
||||||
|
}
|
||||||
.btn.primary {
|
.btn.primary {
|
||||||
background: #3b82f6;
|
background: #3b82f6;
|
||||||
color: #fff;
|
color: #fff;
|
||||||
|
|||||||
Vendored
+10
@@ -13,6 +13,10 @@ export function ConfirmNativeRegion(arg1:main.CaptureResult):Promise<void>;
|
|||||||
|
|
||||||
export function ConfirmRegion(arg1:main.CaptureResult):Promise<void>;
|
export function ConfirmRegion(arg1:main.CaptureResult):Promise<void>;
|
||||||
|
|
||||||
|
export function CopyNativeRegionImage(arg1:main.CaptureResult):Promise<void>;
|
||||||
|
|
||||||
|
export function CopyRegionImage(arg1:main.CaptureResult):Promise<void>;
|
||||||
|
|
||||||
export function GetConfig():Promise<domain.AppConfig>;
|
export function GetConfig():Promise<domain.AppConfig>;
|
||||||
|
|
||||||
export function QuitApp():Promise<void>;
|
export function QuitApp():Promise<void>;
|
||||||
@@ -21,6 +25,12 @@ export function RetryRegisterHotkey():Promise<void>;
|
|||||||
|
|
||||||
export function SaveConfig(arg1:domain.AppConfig):Promise<void>;
|
export function SaveConfig(arg1:domain.AppConfig):Promise<void>;
|
||||||
|
|
||||||
|
export function SaveNativeRegionImage(arg1:main.CaptureResult):Promise<main.CaptureActionResult>;
|
||||||
|
|
||||||
|
export function SaveNativeRegionImageToDir(arg1:main.CaptureResult,arg2:string):Promise<main.CaptureActionResult>;
|
||||||
|
|
||||||
|
export function SaveRegionImage(arg1:main.CaptureResult):Promise<main.CaptureActionResult>;
|
||||||
|
|
||||||
export function ShowWindow():Promise<void>;
|
export function ShowWindow():Promise<void>;
|
||||||
|
|
||||||
export function TestConnection(arg1:domain.S3Config):Promise<void>;
|
export function TestConnection(arg1:domain.S3Config):Promise<void>;
|
||||||
|
|||||||
@@ -22,6 +22,14 @@ export function ConfirmRegion(arg1) {
|
|||||||
return window['go']['main']['App']['ConfirmRegion'](arg1);
|
return window['go']['main']['App']['ConfirmRegion'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function CopyNativeRegionImage(arg1) {
|
||||||
|
return window['go']['main']['App']['CopyNativeRegionImage'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function CopyRegionImage(arg1) {
|
||||||
|
return window['go']['main']['App']['CopyRegionImage'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function GetConfig() {
|
export function GetConfig() {
|
||||||
return window['go']['main']['App']['GetConfig']();
|
return window['go']['main']['App']['GetConfig']();
|
||||||
}
|
}
|
||||||
@@ -38,6 +46,18 @@ export function SaveConfig(arg1) {
|
|||||||
return window['go']['main']['App']['SaveConfig'](arg1);
|
return window['go']['main']['App']['SaveConfig'](arg1);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function SaveNativeRegionImage(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveNativeRegionImage'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveNativeRegionImageToDir(arg1, arg2) {
|
||||||
|
return window['go']['main']['App']['SaveNativeRegionImageToDir'](arg1, arg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function SaveRegionImage(arg1) {
|
||||||
|
return window['go']['main']['App']['SaveRegionImage'](arg1);
|
||||||
|
}
|
||||||
|
|
||||||
export function ShowWindow() {
|
export function ShowWindow() {
|
||||||
return window['go']['main']['App']['ShowWindow']();
|
return window['go']['main']['App']['ShowWindow']();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
export namespace application {
|
export namespace application {
|
||||||
|
|
||||||
export class Point {
|
export class Point {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Point(source);
|
return new Point(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.x = source["x"];
|
this.x = source["x"];
|
||||||
@@ -18,18 +18,18 @@ export namespace application {
|
|||||||
tool: string;
|
tool: string;
|
||||||
color: string;
|
color: string;
|
||||||
points: Point[];
|
points: Point[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new Annotation(source);
|
return new Annotation(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.tool = source["tool"];
|
this.tool = source["tool"];
|
||||||
this.color = source["color"];
|
this.color = source["color"];
|
||||||
this.points = this.convertValues(source["points"], Point);
|
this.points = this.convertValues(source["points"], Point);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
if (!a) {
|
if (!a) {
|
||||||
return a;
|
return a;
|
||||||
@@ -52,7 +52,7 @@ export namespace application {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace domain {
|
export namespace domain {
|
||||||
|
|
||||||
export class S3Config {
|
export class S3Config {
|
||||||
endpoint: string;
|
endpoint: string;
|
||||||
region: string;
|
region: string;
|
||||||
@@ -62,11 +62,11 @@ export namespace domain {
|
|||||||
pathPrefix: string;
|
pathPrefix: string;
|
||||||
publicUrlBase: string;
|
publicUrlBase: string;
|
||||||
usePathStyle: boolean;
|
usePathStyle: boolean;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new S3Config(source);
|
return new S3Config(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.endpoint = source["endpoint"];
|
this.endpoint = source["endpoint"];
|
||||||
@@ -82,17 +82,17 @@ export namespace domain {
|
|||||||
export class AppConfig {
|
export class AppConfig {
|
||||||
hotkey: string;
|
hotkey: string;
|
||||||
s3: S3Config;
|
s3: S3Config;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new AppConfig(source);
|
return new AppConfig(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.hotkey = source["hotkey"];
|
this.hotkey = source["hotkey"];
|
||||||
this.s3 = this.convertValues(source["s3"], S3Config);
|
this.s3 = this.convertValues(source["s3"], S3Config);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
if (!a) {
|
if (!a) {
|
||||||
return a;
|
return a;
|
||||||
@@ -115,17 +115,31 @@ export namespace domain {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export namespace main {
|
export namespace main {
|
||||||
|
|
||||||
|
export class CaptureActionResult {
|
||||||
|
completed: boolean;
|
||||||
|
path?: string;
|
||||||
|
|
||||||
|
static createFrom(source: any = {}) {
|
||||||
|
return new CaptureActionResult(source);
|
||||||
|
}
|
||||||
|
|
||||||
|
constructor(source: any = {}) {
|
||||||
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
|
this.completed = source["completed"];
|
||||||
|
this.path = source["path"];
|
||||||
|
}
|
||||||
|
}
|
||||||
export class RegionRect {
|
export class RegionRect {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
w: number;
|
w: number;
|
||||||
h: number;
|
h: number;
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new RegionRect(source);
|
return new RegionRect(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.x = source["x"];
|
this.x = source["x"];
|
||||||
@@ -137,17 +151,17 @@ export namespace main {
|
|||||||
export class CaptureResult {
|
export class CaptureResult {
|
||||||
rect: RegionRect;
|
rect: RegionRect;
|
||||||
annotations: application.Annotation[];
|
annotations: application.Annotation[];
|
||||||
|
|
||||||
static createFrom(source: any = {}) {
|
static createFrom(source: any = {}) {
|
||||||
return new CaptureResult(source);
|
return new CaptureResult(source);
|
||||||
}
|
}
|
||||||
|
|
||||||
constructor(source: any = {}) {
|
constructor(source: any = {}) {
|
||||||
if ('string' === typeof source) source = JSON.parse(source);
|
if ('string' === typeof source) source = JSON.parse(source);
|
||||||
this.rect = this.convertValues(source["rect"], RegionRect);
|
this.rect = this.convertValues(source["rect"], RegionRect);
|
||||||
this.annotations = this.convertValues(source["annotations"], application.Annotation);
|
this.annotations = this.convertValues(source["annotations"], application.Annotation);
|
||||||
}
|
}
|
||||||
|
|
||||||
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
convertValues(a: any, classs: any, asMap: boolean = false): any {
|
||||||
if (!a) {
|
if (!a) {
|
||||||
return a;
|
return a;
|
||||||
|
|||||||
@@ -0,0 +1,88 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/mmmy/snapgo/internal/infrastructure/clipboard"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CaptureActionsService handles non-upload actions on already-produced PNG
|
||||||
|
// screenshots. Keeping these actions here makes save/copy available across
|
||||||
|
// overlay implementations without binding them to Wails or a specific OS.
|
||||||
|
type CaptureActionsService struct {
|
||||||
|
Clipboard clipboard.Writer
|
||||||
|
Notifier Notifier
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaptureActionsService) CopyImage(_ context.Context, pngBytes []byte) error {
|
||||||
|
if len(pngBytes) == 0 {
|
||||||
|
err := fmt.Errorf("empty screenshot")
|
||||||
|
s.notifyFailure(err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.Clipboard == nil {
|
||||||
|
err := fmt.Errorf("clipboard is not configured")
|
||||||
|
s.notifyFailure(err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := s.Clipboard.WriteImage(pngBytes); err != nil {
|
||||||
|
s.notifyFailure("clipboard write failed: " + err.Error())
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if s.Notifier != nil {
|
||||||
|
s.Notifier.NotifySuccess("image copied to clipboard")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaptureActionsService) SaveImage(_ context.Context, pngBytes []byte, dir string) (string, error) {
|
||||||
|
if len(pngBytes) == 0 {
|
||||||
|
err := fmt.Errorf("empty screenshot")
|
||||||
|
s.notifyFailure(err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
dir = strings.TrimSpace(dir)
|
||||||
|
if dir == "" {
|
||||||
|
err := fmt.Errorf("save directory is empty")
|
||||||
|
s.notifyFailure(err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
s.notifyFailure("create save directory failed: " + err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
path := uniquePNGPath(dir, time.Now())
|
||||||
|
if err := os.WriteFile(path, pngBytes, 0o644); err != nil {
|
||||||
|
s.notifyFailure("save failed: " + err.Error())
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if s.Notifier != nil {
|
||||||
|
s.Notifier.NotifySuccess(path)
|
||||||
|
}
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *CaptureActionsService) notifyFailure(reason string) {
|
||||||
|
if s.Notifier != nil {
|
||||||
|
s.Notifier.NotifyFailure(reason)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func uniquePNGPath(dir string, now time.Time) string {
|
||||||
|
base := now.Format("20060102-150405")
|
||||||
|
path := filepath.Join(dir, base+".png")
|
||||||
|
for i := 1; fileExists(path); i++ {
|
||||||
|
path = filepath.Join(dir, fmt.Sprintf("%s-%02d.png", base, i))
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
func fileExists(path string) bool {
|
||||||
|
_, err := os.Stat(path)
|
||||||
|
return err == nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
package application
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
type fakeClipboard struct {
|
||||||
|
text string
|
||||||
|
image []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeClipboard) WriteText(s string) error {
|
||||||
|
f.text = s
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeClipboard) WriteImage(pngBytes []byte) error {
|
||||||
|
f.image = append([]byte(nil), pngBytes...)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type fakeNotifier struct {
|
||||||
|
success string
|
||||||
|
failure string
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeNotifier) NotifySuccess(value string) { f.success = value }
|
||||||
|
func (f *fakeNotifier) NotifyFailure(reason string) { f.failure = reason }
|
||||||
|
|
||||||
|
func TestCaptureActionsCopyImageWritesPNGToClipboard(t *testing.T) {
|
||||||
|
clip := &fakeClipboard{}
|
||||||
|
notifier := &fakeNotifier{}
|
||||||
|
svc := &CaptureActionsService{Clipboard: clip, Notifier: notifier}
|
||||||
|
|
||||||
|
err := svc.CopyImage(context.Background(), []byte("png"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("copy image: %v", err)
|
||||||
|
}
|
||||||
|
if string(clip.image) != "png" {
|
||||||
|
t.Fatalf("expected image bytes copied, got %q", string(clip.image))
|
||||||
|
}
|
||||||
|
if notifier.success != "image copied to clipboard" {
|
||||||
|
t.Fatalf("expected copy success notification, got %q", notifier.success)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestCaptureActionsSaveImageWritesUniquePNG(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
svc := &CaptureActionsService{Notifier: &fakeNotifier{}}
|
||||||
|
|
||||||
|
path, err := svc.SaveImage(context.Background(), []byte("png"), dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save image: %v", err)
|
||||||
|
}
|
||||||
|
if filepath.Dir(path) != dir {
|
||||||
|
t.Fatalf("expected save in %q, got %q", dir, path)
|
||||||
|
}
|
||||||
|
if data, err := os.ReadFile(path); err != nil || string(data) != "png" {
|
||||||
|
t.Fatalf("expected saved bytes, data=%q err=%v", string(data), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
next, err := svc.SaveImage(context.Background(), []byte("png2"), dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("save second image: %v", err)
|
||||||
|
}
|
||||||
|
if next == path {
|
||||||
|
t.Fatalf("expected unique path for second save")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
// Package clipboard wraps golang.design/x/clipboard with lazy initialization.
|
// Package clipboard wraps golang.design/x/clipboard with lazy initialization.
|
||||||
//
|
//
|
||||||
// Why a wrapper:
|
// Why a wrapper:
|
||||||
// - The upstream library requires a one-time clipboard.Init() call. Hiding it
|
// - The upstream library requires a one-time clipboard.Init() call. Hiding it
|
||||||
// here keeps the application service free of init concerns.
|
// here keeps the application service free of init concerns.
|
||||||
// - Allows future swap to a different backend (e.g. atotto/clipboard) without
|
// - Allows future swap to a different backend (e.g. atotto/clipboard) without
|
||||||
// changing callers.
|
// changing callers.
|
||||||
package clipboard
|
package clipboard
|
||||||
|
|
||||||
import (
|
import (
|
||||||
@@ -17,6 +17,7 @@ import (
|
|||||||
// Writer abstracts clipboard writes for testability.
|
// Writer abstracts clipboard writes for testability.
|
||||||
type Writer interface {
|
type Writer interface {
|
||||||
WriteText(s string) error
|
WriteText(s string) error
|
||||||
|
WriteImage(pngBytes []byte) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type clipWriter struct {
|
type clipWriter struct {
|
||||||
@@ -44,3 +45,12 @@ func (w *clipWriter) WriteText(s string) error {
|
|||||||
clipboard.Write(clipboard.FmtText, []byte(s))
|
clipboard.Write(clipboard.FmtText, []byte(s))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// WriteImage replaces the clipboard contents with PNG-encoded image data.
|
||||||
|
func (w *clipWriter) WriteImage(pngBytes []byte) error {
|
||||||
|
if err := w.ensureInit(); err != nil {
|
||||||
|
return fmt.Errorf("clipboard init: %w", err)
|
||||||
|
}
|
||||||
|
clipboard.Write(clipboard.FmtImage, pngBytes)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,35 +4,67 @@ package main
|
|||||||
|
|
||||||
import "C"
|
import "C"
|
||||||
|
|
||||||
//export nativeOverlayConfirm
|
func nativeCaptureResult(x, y, w, h C.int, annotationsJSON *C.char) CaptureResult {
|
||||||
func nativeOverlayConfirm(x, y, w, h C.int, annotationsJSON *C.char) {
|
rawAnnotations := C.GoString(annotationsJSON)
|
||||||
|
return CaptureResult{
|
||||||
|
Rect: RegionRect{
|
||||||
|
X: int(x),
|
||||||
|
Y: int(y),
|
||||||
|
W: int(w),
|
||||||
|
H: int(h),
|
||||||
|
},
|
||||||
|
Annotations: parseNativeAnnotations(rawAnnotations),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func consumeNativeOverlayApp() *App {
|
||||||
nativeOverlayState.Lock()
|
nativeOverlayState.Lock()
|
||||||
app := nativeOverlayState.app
|
app := nativeOverlayState.app
|
||||||
nativeOverlayState.app = nil
|
nativeOverlayState.app = nil
|
||||||
nativeOverlayState.Unlock()
|
nativeOverlayState.Unlock()
|
||||||
|
return app
|
||||||
|
}
|
||||||
|
|
||||||
|
//export nativeOverlayConfirm
|
||||||
|
func nativeOverlayConfirm(x, y, w, h C.int, annotationsJSON *C.char) {
|
||||||
|
app := consumeNativeOverlayApp()
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
rawAnnotations := C.GoString(annotationsJSON)
|
result := nativeCaptureResult(x, y, w, h, annotationsJSON)
|
||||||
go func() {
|
go func() {
|
||||||
_ = app.ConfirmNativeRegion(CaptureResult{
|
_ = app.ConfirmNativeRegion(result)
|
||||||
Rect: RegionRect{
|
}()
|
||||||
X: int(x),
|
}
|
||||||
Y: int(y),
|
|
||||||
W: int(w),
|
//export nativeOverlayCopy
|
||||||
H: int(h),
|
func nativeOverlayCopy(x, y, w, h C.int, annotationsJSON *C.char) {
|
||||||
},
|
app := consumeNativeOverlayApp()
|
||||||
Annotations: parseNativeAnnotations(rawAnnotations),
|
if app == nil {
|
||||||
})
|
return
|
||||||
|
}
|
||||||
|
result := nativeCaptureResult(x, y, w, h, annotationsJSON)
|
||||||
|
go func() {
|
||||||
|
_ = app.CopyNativeRegionImage(result)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
//export nativeOverlaySave
|
||||||
|
func nativeOverlaySave(x, y, w, h C.int, annotationsJSON *C.char, dir *C.char) {
|
||||||
|
app := consumeNativeOverlayApp()
|
||||||
|
if app == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result := nativeCaptureResult(x, y, w, h, annotationsJSON)
|
||||||
|
saveDir := C.GoString(dir)
|
||||||
|
go func() {
|
||||||
|
_, _ = app.SaveNativeRegionImageToDir(result, saveDir)
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
||||||
//export nativeOverlayCancel
|
//export nativeOverlayCancel
|
||||||
func nativeOverlayCancel() {
|
func nativeOverlayCancel() {
|
||||||
nativeOverlayState.Lock()
|
app := consumeNativeOverlayApp()
|
||||||
app := nativeOverlayState.app
|
|
||||||
nativeOverlayState.app = nil
|
|
||||||
nativeOverlayState.Unlock()
|
|
||||||
if app == nil {
|
if app == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
+69
-21
@@ -11,6 +11,8 @@ package main
|
|||||||
#import <AppKit/AppKit.h>
|
#import <AppKit/AppKit.h>
|
||||||
|
|
||||||
extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotationsJSON);
|
extern void nativeOverlayConfirm(int x, int y, int w, int h, const char *annotationsJSON);
|
||||||
|
extern void nativeOverlayCopy(int x, int y, int w, int h, const char *annotationsJSON);
|
||||||
|
extern void nativeOverlaySave(int x, int y, int w, int h, const char *annotationsJSON, const char *dir);
|
||||||
extern void nativeOverlayCancel(void);
|
extern void nativeOverlayCancel(void);
|
||||||
|
|
||||||
static NSWindow *nativeOverlayWindow = nil;
|
static NSWindow *nativeOverlayWindow = nil;
|
||||||
@@ -40,6 +42,7 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
@property(strong) NSMutableArray<NSDictionary *> *annotations;
|
@property(strong) NSMutableArray<NSDictionary *> *annotations;
|
||||||
@property(strong) NSMutableDictionary *draftAnnotation;
|
@property(strong) NSMutableDictionary *draftAnnotation;
|
||||||
@property(strong) NSButton *cancelButton;
|
@property(strong) NSButton *cancelButton;
|
||||||
|
@property(strong) NSButton *saveButton;
|
||||||
@property(strong) NSButton *uploadButton;
|
@property(strong) NSButton *uploadButton;
|
||||||
@property(strong) NSButton *penButton;
|
@property(strong) NSButton *penButton;
|
||||||
@property(strong) NSButton *rectButton;
|
@property(strong) NSButton *rectButton;
|
||||||
@@ -52,6 +55,8 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
- (void)syncControls;
|
- (void)syncControls;
|
||||||
- (void)styleControls;
|
- (void)styleControls;
|
||||||
- (void)confirmSelection;
|
- (void)confirmSelection;
|
||||||
|
- (void)copySelection;
|
||||||
|
- (void)saveSelection;
|
||||||
- (void)cancelSelection;
|
- (void)cancelSelection;
|
||||||
@end
|
@end
|
||||||
|
|
||||||
@@ -70,6 +75,7 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
_annotations = [NSMutableArray array];
|
_annotations = [NSMutableArray array];
|
||||||
|
|
||||||
_cancelButton = [NSButton buttonWithTitle:@"Cancel" target:self action:@selector(cancelSelection)];
|
_cancelButton = [NSButton buttonWithTitle:@"Cancel" target:self action:@selector(cancelSelection)];
|
||||||
|
_saveButton = [NSButton buttonWithTitle:@"Save" target:self action:@selector(saveSelection)];
|
||||||
_uploadButton = [NSButton buttonWithTitle:@"Upload & copy" target:self action:@selector(confirmSelection)];
|
_uploadButton = [NSButton buttonWithTitle:@"Upload & copy" target:self action:@selector(confirmSelection)];
|
||||||
_penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)];
|
_penButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectPen)];
|
||||||
_rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)];
|
_rectButton = [NSButton buttonWithTitle:@"" target:self action:@selector(selectRect)];
|
||||||
@@ -81,10 +87,10 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
_hintLabel = [NSTextField labelWithString:@"Drag to select an area · Esc to cancel"];
|
_hintLabel = [NSTextField labelWithString:@"Drag to select an area · Esc to cancel"];
|
||||||
[self styleControls];
|
[self styleControls];
|
||||||
|
|
||||||
for (NSView *view in @[_cancelButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) {
|
for (NSView *view in @[_cancelButton, _saveButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView, _sizeLabel, _hintLabel]) {
|
||||||
[self addSubview:view];
|
[self addSubview:view];
|
||||||
}
|
}
|
||||||
for (NSView *view in @[_cancelButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView]) {
|
for (NSView *view in @[_cancelButton, _saveButton, _uploadButton, _penButton, _rectButton, _ellipseButton, _colorButton, _undoButton, _paletteView]) {
|
||||||
[view setHidden:YES];
|
[view setHidden:YES];
|
||||||
}
|
}
|
||||||
[_sizeLabel setHidden:YES];
|
[_sizeLabel setHidden:YES];
|
||||||
@@ -150,6 +156,9 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
[self styleButton:_cancelButton
|
[self styleButton:_cancelButton
|
||||||
background:[NSColor colorWithCalibratedWhite:0.12 alpha:0.96]
|
background:[NSColor colorWithCalibratedWhite:0.12 alpha:0.96]
|
||||||
foreground:[NSColor colorWithCalibratedWhite:0.86 alpha:1.0]];
|
foreground:[NSColor colorWithCalibratedWhite:0.86 alpha:1.0]];
|
||||||
|
[self styleButton:_saveButton
|
||||||
|
background:[NSColor colorWithCalibratedWhite:0.18 alpha:0.96]
|
||||||
|
foreground:[NSColor whiteColor]];
|
||||||
[self styleButton:_uploadButton
|
[self styleButton:_uploadButton
|
||||||
background:[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0]
|
background:[NSColor colorWithCalibratedRed:59.0/255.0 green:130.0/255.0 blue:246.0/255.0 alpha:1.0]
|
||||||
foreground:[NSColor whiteColor]];
|
foreground:[NSColor whiteColor]];
|
||||||
@@ -310,6 +319,10 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
|
|
||||||
- (void)mouseDown:(NSEvent *)event {
|
- (void)mouseDown:(NSEvent *)event {
|
||||||
NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil];
|
NSPoint p = [self convertPoint:[event locationInWindow] fromView:nil];
|
||||||
|
if ([event clickCount] == 2 && _hasSelection && NSPointInRect(p, _selection)) {
|
||||||
|
[self copySelection];
|
||||||
|
return;
|
||||||
|
}
|
||||||
NSString *handle = [self resizeHandleAtPoint:p];
|
NSString *handle = [self resizeHandleAtPoint:p];
|
||||||
if (handle != nil) {
|
if (handle != nil) {
|
||||||
_resizing = YES;
|
_resizing = YES;
|
||||||
@@ -426,6 +439,7 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
- (void)syncControls {
|
- (void)syncControls {
|
||||||
BOOL visible = _hasSelection && _selection.size.width >= 4 && _selection.size.height >= 4;
|
BOOL visible = _hasSelection && _selection.size.width >= 4 && _selection.size.height >= 4;
|
||||||
[_cancelButton setHidden:!visible];
|
[_cancelButton setHidden:!visible];
|
||||||
|
[_saveButton setHidden:!visible];
|
||||||
[_uploadButton setHidden:!visible];
|
[_uploadButton setHidden:!visible];
|
||||||
[_penButton setHidden:!visible];
|
[_penButton setHidden:!visible];
|
||||||
[_rectButton setHidden:!visible];
|
[_rectButton setHidden:!visible];
|
||||||
@@ -442,7 +456,7 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
[_sizeLabel setStringValue:[NSString stringWithFormat:@"%.0f × %.0f", _selection.size.width, _selection.size.height]];
|
[_sizeLabel setStringValue:[NSString stringWithFormat:@"%.0f × %.0f", _selection.size.width, _selection.size.height]];
|
||||||
[_sizeLabel setFrame:NSMakeRect(_selection.origin.x, MAX(0, _selection.origin.y - 26), 110, 22)];
|
[_sizeLabel setFrame:NSMakeRect(_selection.origin.x, MAX(0, _selection.origin.y - 26), 110, 22)];
|
||||||
|
|
||||||
CGFloat toolbarW = 220;
|
CGFloat toolbarW = 292;
|
||||||
CGFloat toolbarH = 40;
|
CGFloat toolbarH = 40;
|
||||||
CGFloat x = _selection.origin.x + _selection.size.width - toolbarW;
|
CGFloat x = _selection.origin.x + _selection.size.width - toolbarW;
|
||||||
CGFloat y = _selection.origin.y + _selection.size.height + 8;
|
CGFloat y = _selection.origin.y + _selection.size.height + 8;
|
||||||
@@ -451,8 +465,9 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
}
|
}
|
||||||
x = MAX(0, MIN(x, self.bounds.size.width - toolbarW));
|
x = MAX(0, MIN(x, self.bounds.size.width - toolbarW));
|
||||||
|
|
||||||
[_cancelButton setFrame:NSMakeRect(x, y, 86, 32)];
|
[_cancelButton setFrame:NSMakeRect(x, y, 74, 32)];
|
||||||
[_uploadButton setFrame:NSMakeRect(x + 92, y, 128, 32)];
|
[_saveButton setFrame:NSMakeRect(x + 80, y, 64, 32)];
|
||||||
|
[_uploadButton setFrame:NSMakeRect(x + 150, y, 142, 32)];
|
||||||
|
|
||||||
CGFloat markW = 170;
|
CGFloat markW = 170;
|
||||||
CGFloat markX = MAX(0, MIN(_selection.origin.x, self.bounds.size.width - markW));
|
CGFloat markX = MAX(0, MIN(_selection.origin.x, self.bounds.size.width - markW));
|
||||||
@@ -547,28 +562,61 @@ static id nativeOverlayKeyMonitor = nil;
|
|||||||
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding];
|
||||||
}
|
}
|
||||||
|
|
||||||
- (void)confirmSelection {
|
- (void)closeOverlayWindow {
|
||||||
if (!_hasSelection) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
NSRect r = _selection;
|
|
||||||
[nativeOverlayWindow orderOut:nil];
|
|
||||||
if (nativeOverlayKeyMonitor != nil) {
|
|
||||||
[NSEvent removeMonitor:nativeOverlayKeyMonitor];
|
|
||||||
nativeOverlayKeyMonitor = nil;
|
|
||||||
}
|
|
||||||
nativeOverlayWindow = nil;
|
|
||||||
NSString *json = [self annotationsJSON];
|
|
||||||
nativeOverlayConfirm((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
|
|
||||||
}
|
|
||||||
|
|
||||||
- (void)cancelSelection {
|
|
||||||
[nativeOverlayWindow orderOut:nil];
|
[nativeOverlayWindow orderOut:nil];
|
||||||
if (nativeOverlayKeyMonitor != nil) {
|
if (nativeOverlayKeyMonitor != nil) {
|
||||||
[NSEvent removeMonitor:nativeOverlayKeyMonitor];
|
[NSEvent removeMonitor:nativeOverlayKeyMonitor];
|
||||||
nativeOverlayKeyMonitor = nil;
|
nativeOverlayKeyMonitor = nil;
|
||||||
}
|
}
|
||||||
nativeOverlayWindow = nil;
|
nativeOverlayWindow = nil;
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)confirmSelection {
|
||||||
|
if (!_hasSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSRect r = _selection;
|
||||||
|
[self closeOverlayWindow];
|
||||||
|
NSString *json = [self annotationsJSON];
|
||||||
|
nativeOverlayConfirm((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)copySelection {
|
||||||
|
if (!_hasSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSRect r = _selection;
|
||||||
|
[self closeOverlayWindow];
|
||||||
|
NSString *json = [self annotationsJSON];
|
||||||
|
nativeOverlayCopy((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String]);
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)saveSelection {
|
||||||
|
if (!_hasSelection) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSRect r = _selection;
|
||||||
|
NSString *json = [self annotationsJSON];
|
||||||
|
[self closeOverlayWindow];
|
||||||
|
|
||||||
|
NSOpenPanel *panel = [NSOpenPanel openPanel];
|
||||||
|
[panel setTitle:@"Save screenshot to folder"];
|
||||||
|
[panel setCanChooseFiles:NO];
|
||||||
|
[panel setCanChooseDirectories:YES];
|
||||||
|
[panel setAllowsMultipleSelection:NO];
|
||||||
|
[panel setCanCreateDirectories:YES];
|
||||||
|
[panel beginWithCompletionHandler:^(NSModalResponse result) {
|
||||||
|
if (result != NSModalResponseOK || [panel URL] == nil) {
|
||||||
|
nativeOverlayCancel();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
NSString *dir = [[panel URL] path];
|
||||||
|
nativeOverlaySave((int)llround(r.origin.x), (int)llround(r.origin.y), (int)llround(r.size.width), (int)llround(r.size.height), [json UTF8String], [dir UTF8String]);
|
||||||
|
}];
|
||||||
|
}
|
||||||
|
|
||||||
|
- (void)cancelSelection {
|
||||||
|
[self closeOverlayWindow];
|
||||||
nativeOverlayCancel();
|
nativeOverlayCancel();
|
||||||
}
|
}
|
||||||
@end
|
@end
|
||||||
|
|||||||
Reference in New Issue
Block a user