Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8fedb54dfe | |||
| bbf20e8270 |
@@ -90,8 +90,11 @@ broker can load them even when the profile directory is inaccessible to it.
|
|||||||
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
Notifications expire after 24 hours. Cache cleanup runs on capture/open; each cache is bounded to
|
||||||
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
40 files and 256 MiB. Expired or evicted notifications cannot reopen their screenshot.
|
||||||
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
In the editor, **Ctrl+C** or **Enter** copies the current result without closing the window.
|
||||||
|
The editor starts with **Pointer (V)**: select objects, move them, and edit geometry, thickness and
|
||||||
|
other properties using handles and controls. Clicking empty space clears selection; dragging empty
|
||||||
|
space adds nothing. Choose a drawing tool to create objects, then return to **V** to edit them.
|
||||||
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
|
To annotate: **N** places sequential numbered red markers with a click, and **A** draws
|
||||||
red arrows by dragging from the tail to the tip. Click an existing marker or arrow to select it;
|
red arrows by dragging from the tail to the tip. With **Pointer (V)**, click an existing marker or arrow to select it;
|
||||||
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
|
drag its body to move it. Drag the square handle on a number's right edge to change its diameter
|
||||||
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
|
(the text scales with it), or either endpoint handle of an arrow to change its length and direction.
|
||||||
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
|
Select an arrow and drag the extra handle beside its midpoint perpendicular to the line to change
|
||||||
@@ -101,6 +104,14 @@ change its diameter. Wheel up increases the size; wheel down decreases it. These
|
|||||||
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
and update linked arrows. **Ctrl+D** clones the selected object with a small offset. A cloned number
|
||||||
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
gets the smallest available positive number, just like a newly placed number: gaps left by deletion
|
||||||
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
are filled first, without renumbering existing objects. A cloned arrow keeps its style but starts without endpoint links.
|
||||||
|
Arrow and line endpoints can attach to numbered markers, rectangles, circles, ellipses, text blocks,
|
||||||
|
and other arrows or strokes. In Arrow or Straight line mode, hold **Ctrl** and drag from one object
|
||||||
|
to another to draw over them and automatically attach both endpoints. Without Ctrl, existing objects
|
||||||
|
can still be selected and edited as before. With Pointer, drag an existing endpoint onto a target to attach it. Attached endpoints follow movement and resizing; text attachments
|
||||||
|
also follow rotation. Arrow-to-arrow attachments retain their relative position along the target line.
|
||||||
|
Clicking a connector or adding/editing its caption preserves its links. Moving the whole connector
|
||||||
|
intentionally detaches its endpoints; moving one endpoint detaches only that end. Deleting a target
|
||||||
|
leaves connected endpoints at their last position. Self-links and circular dependency chains are rejected.
|
||||||
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
|
The compact toolbar uses [Lucide](https://lucide.dev) icons with hover tooltips showing each action
|
||||||
and its keyboard shortcut.
|
and its keyboard shortcut.
|
||||||
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
|
Drop either arrow endpoint onto a number to link it to the circle's edge. Linked endpoints follow
|
||||||
@@ -129,6 +140,8 @@ The mouse wheel over text adjusts its font size. Text supports color, dragging,
|
|||||||
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
|
Delete and Undo/Redo. Arrow keys move the selected object by one source-image pixel;
|
||||||
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
|
selecting an arrow caption moves only that caption. Repositioning a caption preserves its
|
||||||
automatic angle; only dragging the rotation handle switches to a fixed angle.
|
automatic angle; only dragging the rotation handle switches to a fixed angle.
|
||||||
|
New standalone text starts horizontally, even after editing an angled arrow caption. Font styling
|
||||||
|
is reused; rotation belongs to each individual object.
|
||||||
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
|
Text has a rotation handle above its selection. Drag to rotate; hold Shift to snap to 15-degree steps.
|
||||||
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
|
Double-click an arrow, press F2, or use the label icon to add/edit its caption. Captions follow
|
||||||
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
|
the arrow when moved or resized; drag the caption to adjust its offset. By default, the caption
|
||||||
|
|||||||
Binary file not shown.
+5
-4
@@ -1,13 +1,14 @@
|
|||||||
# Latest Windows release
|
# Latest Windows release
|
||||||
|
|
||||||
## v0.6.4 — 2026-10-29
|
## v0.6.6 — 2026-10-29
|
||||||
|
|
||||||
## Changes
|
## Changes
|
||||||
- Fix accidental arrow/line detachment when dragging two linked numbered markers in turn. Clicking a visible marker now selects the marker instead of a connector endpoint underneath it, including when the connector is selected.
|
- Add **Pointer (V)** as the initial editor tool. Select and move objects, edit geometry and thickness with handles, and clear selection by clicking empty space. Pointer never creates objects on empty space.
|
||||||
- Preserve intentional endpoint dragging outside the marker.
|
- Preserve **Ctrl+drag** for drawing over existing objects and automatically linking connector endpoints. Add clearer Ctrl hints to the Arrow and Straight line buttons and status bar.
|
||||||
|
- Fix new standalone text inheriting the angle of an arrow caption. New text reuses typography only and starts horizontally; editing existing rotated text preserves its own angle.
|
||||||
|
|
||||||
## Validation
|
## Validation
|
||||||
176 native checks, 403 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. The new UI regression test fails before the fix and passes afterward, covering alternating marker drags with arrows, lines and freehand connectors, both endpoint links, and endpoint selection outside markers.
|
176 native checks, 440 input/layout checks at 240 DPI, screenshot editor UI integration, isolated notification registration, and 45 updater checks. UI coverage includes Pointer interactions, Ctrl drawing between markers/shapes/arrows, and standalone text after automatic or manually rotated arrow captions.
|
||||||
|
|
||||||
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the
|
**Upgrade:** choose Update in the tray menu. Legacy v0.3.0–v0.4.1 clients first receive the
|
||||||
v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
v0.4.2 compatibility bridge, then the current full release. v0.2.3 and earlier require manual installation.
|
||||||
|
|||||||
@@ -1 +1 @@
|
|||||||
da86748a476deec9323c39e119c89dc008e2fe62691b207eb3b0f3bb0da32cf6 KSwitcher-win-x64.zip
|
cb711ace317e60054f6498436f02bd66f10adebfe9362b8d1c106569cd27a600 KSwitcher-win-x64.zip
|
||||||
|
|||||||
+3
-3
@@ -9,8 +9,8 @@ IDC_SCREENSHOT_PENCIL CURSOR "icons/lucide/pencil.cur"
|
|||||||
1 RT_MANIFEST "app.manifest"
|
1 RT_MANIFEST "app.manifest"
|
||||||
201 RCDATA "update.ps1"
|
201 RCDATA "update.ps1"
|
||||||
1 VERSIONINFO
|
1 VERSIONINFO
|
||||||
FILEVERSION 0,6,4,0
|
FILEVERSION 0,6,6,0
|
||||||
PRODUCTVERSION 0,6,4,0
|
PRODUCTVERSION 0,6,6,0
|
||||||
FILEOS VOS_NT_WINDOWS32
|
FILEOS VOS_NT_WINDOWS32
|
||||||
FILETYPE VFT_APP
|
FILETYPE VFT_APP
|
||||||
BEGIN
|
BEGIN
|
||||||
@@ -20,7 +20,7 @@ BEGIN
|
|||||||
BEGIN
|
BEGIN
|
||||||
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
VALUE "FileDescription", "KSwitcher — native keyboard and clipboard utility"
|
||||||
VALUE "ProductName", "KSwitcher"
|
VALUE "ProductName", "KSwitcher"
|
||||||
VALUE "FileVersion", "0.6.4"
|
VALUE "FileVersion", "0.6.6"
|
||||||
END
|
END
|
||||||
END
|
END
|
||||||
BLOCK "VarFileInfo"
|
BLOCK "VarFileInfo"
|
||||||
|
|||||||
+147
-31
@@ -3,6 +3,8 @@
|
|||||||
#include "../resources/resource.h"
|
#include "../resources/resource.h"
|
||||||
#include <algorithm>
|
#include <algorithm>
|
||||||
#include <cmath>
|
#include <cmath>
|
||||||
|
#include <functional>
|
||||||
|
#include <unordered_set>
|
||||||
#include <windowsx.h>
|
#include <windowsx.h>
|
||||||
#include <commctrl.h>
|
#include <commctrl.h>
|
||||||
#include <commdlg.h>
|
#include <commdlg.h>
|
||||||
@@ -51,7 +53,7 @@ bool confirmScreenshotClose(HWND owner)
|
|||||||
// If Windows cannot display the confirmation, keep the screenshot open.
|
// If Windows cannot display the confirmation, keep the screenshot open.
|
||||||
return SUCCEEDED(result) && button==IDYES;
|
return SUCCEEDED(result) && button==IDYES;
|
||||||
}
|
}
|
||||||
constexpr int ToolIds[]{10,11,22,23,24,25,21,19,26,20,12,18,13};
|
constexpr int ToolIds[]{27,10,11,22,23,24,25,21,19,26,20,12,18,13};
|
||||||
struct Surface
|
struct Surface
|
||||||
{
|
{
|
||||||
HDC dc = CreateCompatibleDC(nullptr);
|
HDC dc = CreateCompatibleDC(nullptr);
|
||||||
@@ -129,7 +131,7 @@ struct Capture
|
|||||||
bool editing{}, dragging{}, arrow{}, owned{}, copied{};
|
bool editing{}, dragging{}, arrow{}, owned{}, copied{};
|
||||||
bool autoOpenEditor;
|
bool autoOpenEditor;
|
||||||
bool highlight{};
|
bool highlight{};
|
||||||
bool freehand=true;
|
bool pointer=true, freehand=false;
|
||||||
bool straight{}, textTool{};
|
bool straight{}, textTool{};
|
||||||
Mark textDefaults{};
|
Mark textDefaults{};
|
||||||
ScreenshotShape shape=ScreenshotShape::None;
|
ScreenshotShape shape=ScreenshotShape::None;
|
||||||
@@ -197,7 +199,7 @@ void updateStatus(Capture &c)
|
|||||||
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
|
else if(part==MarkPart::Rotation) text=L"Drag to rotate text | Hold Shift for 15-degree steps";
|
||||||
else if(part==MarkPart::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size";
|
else if(part==MarkPart::Thickness) text=L"Drag to change thickness | Mouse wheel over object = size";
|
||||||
else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter";
|
else if(part==MarkPart::Radius) text=L"Drag to resize the number | Mouse wheel = diameter";
|
||||||
else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Arrow endpoints can snap to numbered markers";
|
else if(part==MarkPart::Start || part==MarkPart::End) text=L"Drag handle to resize | Endpoints can snap to objects and other arrows";
|
||||||
else if(c.selected>=0) {
|
else if(c.selected>=0) {
|
||||||
const auto &m=c.marks[c.selected];
|
const auto &m=c.marks[c.selected];
|
||||||
if(c.selectedLabel || part==MarkPart::Label)
|
if(c.selectedLabel || part==MarkPart::Label)
|
||||||
@@ -205,11 +207,12 @@ void updateStatus(Capture &c)
|
|||||||
else if(m.isText()) text=L"Text: arrows = 1 px | F2 = edit | Round handle = rotate; Shift = 15 degrees";
|
else if(m.isText()) text=L"Text: arrows = 1 px | F2 = edit | Round handle = rotate; Shift = 15 degrees";
|
||||||
else text=L"Arrows = 1 px | Del = delete | Ctrl+D = clone | Ctrl+Z / Ctrl+Y = undo / redo";
|
else text=L"Arrows = 1 px | Del = delete | Ctrl+D = clone | Ctrl+Z / Ctrl+Y = undo / redo";
|
||||||
}
|
}
|
||||||
|
else if(c.pointer) text=L"Pointer (V): select and edit objects | Drag handles = geometry or thickness | Empty space clears selection";
|
||||||
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
else if(c.textTool) text=L"Text (T): click the image to type | Double-click existing text to edit";
|
||||||
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = draw over an object";
|
else if(c.highlight) text=L"Highlight (H): drag to paint | Ctrl+drag = paint over an object | V = pointer";
|
||||||
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object";
|
else if(c.freehand) text=L"Freehand (F): drag to draw | Ctrl+drag = draw over an object | V = pointer";
|
||||||
else if(c.arrow) text=L"Drag to draw | Ctrl+drag = draw over an object | F2 on a line = caption";
|
else if(c.arrow) text=L"Drag to draw | Ctrl+drag over objects = linked connector | V = pointer";
|
||||||
else text=L"Number (N): click to add | Drag objects to move | Ctrl+C = copy screenshot";
|
else text=L"Number (N): click to add | V = select and edit | Ctrl+C = copy screenshot";
|
||||||
if(text!=c.statusText) {
|
if(text!=c.statusText) {
|
||||||
c.statusText=std::move(text);
|
c.statusText=std::move(text);
|
||||||
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
SendMessageW(status,SB_SETTEXTW,0,(LPARAM)c.statusText.c_str());
|
||||||
@@ -250,6 +253,15 @@ void positionInlineText(Capture &c)
|
|||||||
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
|
int height=std::min(std::max(40,MulDiv(bounds.bottom-bounds.top,viewWidth,c.image->width)+24),std::max(32,int(client.bottom)-c.statusHeight-y));
|
||||||
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
SetWindowPos(c.inlineText,nullptr,x-2,y-2,width,height,SWP_NOZORDER|SWP_NOACTIVATE);
|
||||||
}
|
}
|
||||||
|
Mark textStyleDefaults(const Mark &source)
|
||||||
|
{
|
||||||
|
// Reuse typography only, never an object's angle, position, identity or links.
|
||||||
|
Mark style{};
|
||||||
|
style.fontName=source.fontName; style.fontSize=source.fontSize;
|
||||||
|
style.fontWeight=source.fontWeight; style.fontItalic=source.fontItalic;
|
||||||
|
style.color=source.color;
|
||||||
|
return style;
|
||||||
|
}
|
||||||
void finishInlineText(HWND w,Capture &c,bool commit)
|
void finishInlineText(HWND w,Capture &c,bool commit)
|
||||||
{
|
{
|
||||||
if(!c.inlineText) return;
|
if(!c.inlineText) return;
|
||||||
@@ -272,8 +284,9 @@ void finishInlineText(HWND w,Capture &c,bool commit)
|
|||||||
} else if(!empty) {
|
} else if(!empty) {
|
||||||
c.remember(c.marks); c.marks.push_back(c.textDraft); c.selected=int(c.marks.size())-1; c.selectedLabel=false;
|
c.remember(c.marks); c.marks.push_back(c.textDraft); c.selected=int(c.marks.size())-1; c.selectedLabel=false;
|
||||||
}
|
}
|
||||||
if(!empty) c.textDefaults=c.textDraft;
|
if(!empty) c.textDefaults=textStyleDefaults(c.textDraft);
|
||||||
}
|
}
|
||||||
|
if(commit) resolveScreenshotLinks(c.marks);
|
||||||
c.textIndex=-1; c.editingLabel=false;
|
c.textIndex=-1; c.editingLabel=false;
|
||||||
InvalidateRect(w,nullptr,FALSE);
|
InvalidateRect(w,nullptr,FALSE);
|
||||||
}
|
}
|
||||||
@@ -536,7 +549,7 @@ void layout(HWND w, Capture &c)
|
|||||||
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
RECT statusRect{}; GetWindowRect(status,&statusRect);
|
||||||
c.statusHeight=statusRect.bottom-statusRect.top;
|
c.statusHeight=statusRect.bottom-statusRect.top;
|
||||||
r.bottom-=c.statusHeight;
|
r.bottom-=c.statusHeight;
|
||||||
const int positions[]{8,44,80,116,152,188,224,260,296,332,380,416,464};
|
const int positions[]{8,44,80,116,152,188,224,260,296,332,368,416,452,500};
|
||||||
static_assert(std::size(positions)==std::size(ToolIds));
|
static_assert(std::size(positions)==std::size(ToolIds));
|
||||||
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
for (size_t i = 0; i < std::size(ToolIds); ++i)
|
||||||
{
|
{
|
||||||
@@ -607,7 +620,7 @@ std::pair<int,MarkPart> markAt(Capture &c, POINT p)
|
|||||||
}
|
}
|
||||||
void captureCursor(HWND w, Capture &c)
|
void captureCursor(HWND w, Capture &c)
|
||||||
{
|
{
|
||||||
LPCWSTR cursor=IDC_CROSS;
|
LPCWSTR cursor=c.editing && c.pointer?IDC_ARROW:IDC_CROSS;
|
||||||
if(c.editing)
|
if(c.editing)
|
||||||
{
|
{
|
||||||
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
POINT p; GetCursorPos(&p); ScreenToClient(w,&p);
|
||||||
@@ -695,7 +708,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
|||||||
{
|
{
|
||||||
auto dc=item.hDC;
|
auto dc=item.hDC;
|
||||||
RECT r=item.rcItem;
|
RECT r=item.rcItem;
|
||||||
bool active=(item.CtlID==10 && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
bool active=(item.CtlID==27 && c.pointer)||(item.CtlID==10 && !c.pointer && !c.arrow && !c.highlight && !c.freehand && !c.textTool)||(item.CtlID==11 && c.arrow && !c.straight && c.shape==ScreenshotShape::None)||
|
||||||
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
(item.CtlID==19 && c.highlight)||(item.CtlID==21 && c.freehand)||
|
||||||
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
|
(item.CtlID==22 && c.straight)||(item.CtlID>=23 && item.CtlID<=25 && int(c.shape)==int(item.CtlID)-22)||(item.CtlID==26 && c.textTool);
|
||||||
bool disabled=item.itemState&ODS_DISABLED;
|
bool disabled=item.itemState&ODS_DISABLED;
|
||||||
@@ -711,7 +724,7 @@ void drawTool(const DRAWITEMSTRUCT &item, const Capture &c)
|
|||||||
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
RoundRect(dc,r.left,r.top,r.right,r.bottom,corner,corner);
|
||||||
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
SelectObject(dc,oldPen); SelectObject(dc,oldBrush); DeleteObject(pen); DeleteObject(brush);
|
||||||
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
int iconIndex=item.CtlID==35?13:item.CtlID==34?14:item.CtlID>=22?int(item.CtlID)-13:item.CtlID==18?5:item.CtlID==19?6:item.CtlID==20?7:item.CtlID==21?8:item.CtlID-10;
|
||||||
auto icon=c.toolIcons[disabled?2:active?1:0][iconIndex];
|
auto icon=item.CtlID==27?(HICON)LoadCursorW(nullptr,IDC_ARROW):c.toolIcons[disabled?2:active?1:0][iconIndex];
|
||||||
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
|
if(icon) DrawIconEx(dc,(r.right-c.iconSize)/2,(r.bottom-c.iconSize)/2,icon,c.iconSize,c.iconSize,0,nullptr,DI_NORMAL);
|
||||||
if(item.CtlID==20) {
|
if(item.CtlID==20) {
|
||||||
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
|
auto color=c.selected>=0?c.marks[c.selected].color:c.currentColor;
|
||||||
@@ -772,7 +785,7 @@ void edit(HWND w, Capture &c)
|
|||||||
InitCommonControlsEx(&statusControls);
|
InitCommonControlsEx(&statusControls);
|
||||||
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
CreateWindowExW(0,STATUSCLASSNAMEW,L"",WS_CHILD|WS_VISIBLE|SBARS_TOOLTIPS,
|
||||||
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
0,0,0,0,w,(HMENU)50,c.app.instance,nullptr);
|
||||||
const wchar_t *labels[]{L"Number (N)", L"Arrow (A)", L"Straight line (L)", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
|
const wchar_t *labels[]{L"Pointer (V): select and edit objects", L"Number (N)", L"Arrow (A); Ctrl+drag between objects to link", L"Straight line (L); Ctrl+drag between objects to link", L"Rectangle (R)", L"Circle (O)", L"Ellipse (E)", L"Freehand line (F); Ctrl+drag to draw over an object", L"Highlight (H); Ctrl+drag to paint over an object",
|
||||||
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
L"Text (T); double-click or F2 to edit", L"Color — selected object and new objects", L"Undo (Ctrl+Z)", L"Redo (Ctrl+Y / Ctrl+Shift+Z)", L"Copy (Ctrl+C)"};
|
||||||
static_assert(std::size(labels)==std::size(ToolIds));
|
static_assert(std::size(labels)==std::size(ToolIds));
|
||||||
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
auto tips=CreateWindowExW(WS_EX_TOPMOST,TOOLTIPS_CLASSW,nullptr,WS_POPUP|TTS_ALWAYSTIP|TTS_NOPREFIX,
|
||||||
@@ -864,7 +877,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
}
|
}
|
||||||
break;
|
break;
|
||||||
case WM_DRAWITEM:
|
case WM_DRAWITEM:
|
||||||
if((wp>=10 && wp<=13) || (wp>=18 && wp<=26) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
if((wp>=10 && wp<=13) || (wp>=18 && wp<=27) || wp==34 || wp==35) { drawTool(*reinterpret_cast<DRAWITEMSTRUCT *>(lp),*c); return TRUE; }
|
||||||
break;
|
break;
|
||||||
case WM_CTLCOLORSTATIC:
|
case WM_CTLCOLORSTATIC:
|
||||||
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
SetBkColor((HDC)wp,RGB(255,255,255)); SetTextColor((HDC)wp,RGB(51,65,85));
|
||||||
@@ -891,7 +904,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
auto draft=c->marks[c->selected];
|
auto draft=c->marks[c->selected];
|
||||||
if(chooseTextFont(w,draft)) {
|
if(chooseTextFont(w,draft)) {
|
||||||
c->remember(c->marks); c->marks[c->selected]=draft;
|
c->remember(c->marks); c->marks[c->selected]=draft;
|
||||||
c->textDefaults=draft.isLine()?screenshotArrowLabel(draft):draft;
|
c->textDefaults=textStyleDefaults(draft);
|
||||||
|
resolveScreenshotLinks(c->marks);
|
||||||
}
|
}
|
||||||
} else chooseTextFont(w,c->textDefaults);
|
} else chooseTextFont(w,c->textDefaults);
|
||||||
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
|
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
@@ -945,7 +959,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
auto &mark=c->marks[c->selected];
|
auto &mark=c->marks[c->selected];
|
||||||
int &value=mark.isText()?mark.fontSize:mark.thickness;
|
int &value=mark.isText()?mark.fontSize:mark.thickness;
|
||||||
int next=std::clamp(value+(LOWORD(wp)==16?1:-1),mark.isText()?6:mark.isHighlight()?4:1,mark.isText()?200:mark.isHighlight()?100:30);
|
int next=std::clamp(value+(LOWORD(wp)==16?1:-1),mark.isText()?6:mark.isHighlight()?4:1,mark.isText()?200:mark.isHighlight()?100:30);
|
||||||
if(next!=value) { c->remember(c->marks); value=next; }
|
if(next!=value) { c->remember(c->marks); value=next; resolveScreenshotLinks(c->marks); }
|
||||||
}
|
}
|
||||||
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
if (LOWORD(wp)==13) { copy(w,*c); return 0; }
|
||||||
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
if (LOWORD(wp)==12 || LOWORD(wp)==18)
|
||||||
@@ -954,8 +968,9 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
if(changed) { c->copied=false; c->selected=-1; c->selectedLabel=false; c->wheelDelta=0; c->wheelTarget=-1; }
|
||||||
c->syncHistory();
|
c->syncHistory();
|
||||||
}
|
}
|
||||||
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=26))
|
if (LOWORD(wp)==10 || LOWORD(wp)==11 || LOWORD(wp)==19 || (LOWORD(wp)>=21 && LOWORD(wp)<=27))
|
||||||
{
|
{
|
||||||
|
c->pointer=LOWORD(wp)==27;
|
||||||
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
c->arrow = LOWORD(wp)==11 || (LOWORD(wp)>=22 && LOWORD(wp)<=25);
|
||||||
c->straight=LOWORD(wp)==22;
|
c->straight=LOWORD(wp)==22;
|
||||||
c->textTool=LOWORD(wp)==26;
|
c->textTool=LOWORD(wp)==26;
|
||||||
@@ -969,7 +984,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
InvalidateRect(GetDlgItem(w,11),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,11),nullptr,FALSE);
|
||||||
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,19),nullptr,FALSE);
|
||||||
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
InvalidateRect(GetDlgItem(w,21),nullptr,FALSE);
|
||||||
for(int id=22;id<=26;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
for(int id=22;id<=27;++id) InvalidateRect(GetDlgItem(w,id),nullptr,FALSE);
|
||||||
}
|
}
|
||||||
|
|
||||||
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
|
SetFocus(w); InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
@@ -1005,6 +1020,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
InvalidateRect(w,nullptr,FALSE);
|
InvalidateRect(w,nullptr,FALSE);
|
||||||
}
|
}
|
||||||
if(!(GetKeyState(VK_CONTROL)&0x8000)) {
|
if(!(GetKeyState(VK_CONTROL)&0x8000)) {
|
||||||
|
if(wp=='V') SendMessageW(w,WM_COMMAND,27,0);
|
||||||
if(wp=='T') SendMessageW(w,WM_COMMAND,26,0);
|
if(wp=='T') SendMessageW(w,WM_COMMAND,26,0);
|
||||||
if(wp=='L') SendMessageW(w,WM_COMMAND,22,0);
|
if(wp=='L') SendMessageW(w,WM_COMMAND,22,0);
|
||||||
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
|
if(wp=='R') SendMessageW(w,WM_COMMAND,23,0);
|
||||||
@@ -1064,6 +1080,7 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
InvalidateRect(w,nullptr,FALSE); return 0;
|
InvalidateRect(w,nullptr,FALSE); return 0;
|
||||||
}
|
}
|
||||||
c->selected=-1; c->selectedLabel=false;
|
c->selected=-1; c->selectedLabel=false;
|
||||||
|
if(c->pointer) { InvalidateRect(w,nullptr,FALSE); updateStatus(*c); return 0; }
|
||||||
}
|
}
|
||||||
if(c->editing && c->textTool) {
|
if(c->editing && c->textTool) {
|
||||||
Mark draft=c->textDefaults; draft.text.clear(); draft.from=draft.to=p; draft.color=c->currentColor;
|
Mark draft=c->textDefaults; draft.text.clear(); draft.from=draft.to=p; draft.color=c->currentColor;
|
||||||
@@ -1135,7 +1152,8 @@ LRESULT CALLBACK captureProc(HWND w, UINT msg, WPARAM wp, LPARAM lp)
|
|||||||
if (!c->editing) { finishSelection(w,*c); return 0; }
|
if (!c->editing) { finishSelection(w,*c); return 0; }
|
||||||
else if(c->part!=MarkPart::None)
|
else if(c->part!=MarkPart::None)
|
||||||
{
|
{
|
||||||
if(c->part==MarkPart::Start || c->part==MarkPart::End)
|
if((c->part==MarkPart::Start || c->part==MarkPart::End) &&
|
||||||
|
(c->start.x!=c->end.x || c->start.y!=c->end.y))
|
||||||
{
|
{
|
||||||
auto &mark=c->marks[c->selected];
|
auto &mark=c->marks[c->selected];
|
||||||
linkScreenshotEndpoint(mark,c->part==MarkPart::Start,c->end,c->marks,6);
|
linkScreenshotEndpoint(mark,c->part==MarkPart::Start,c->end,c->marks,6);
|
||||||
@@ -1362,27 +1380,107 @@ ScreenshotMark cloneScreenshotMark(const ScreenshotMark &original,const std::vec
|
|||||||
{
|
{
|
||||||
auto result=original;
|
auto result=original;
|
||||||
result.fromNumber=result.toNumber=0;
|
result.fromNumber=result.toNumber=0;
|
||||||
|
result.id=0; result.fromLink={}; result.toLink={};
|
||||||
if(result.number) result.number=nextScreenshotNumber(marks);
|
if(result.number) result.number=nextScreenshotNumber(marks);
|
||||||
auto bounds=markBounds(result);
|
auto bounds=markBounds(result);
|
||||||
LONG dx=bounds.right+16<width?16:-16;
|
LONG dx=bounds.right+16<width?16:-16;
|
||||||
LONG dy=bounds.bottom+16<height?16:-16;
|
LONG dy=bounds.bottom+16<height?16:-16;
|
||||||
return dragScreenshotMark(result,MarkPart::Body,{0,0},{dx,dy},width,height);
|
return dragScreenshotMark(result,MarkPart::Body,{0,0},{dx,dy},width,height);
|
||||||
}
|
}
|
||||||
void linkScreenshotEndpoint(ScreenshotMark &arrow, bool start, POINT drop,
|
namespace {
|
||||||
const std::vector<ScreenshotMark> &marks, double tolerance)
|
void assignMarkIds(std::vector<ScreenshotMark> &marks)
|
||||||
{
|
{
|
||||||
auto &link=start?arrow.fromNumber:arrow.toNumber;
|
static unsigned long long next=1;
|
||||||
link=0;
|
for(const auto &m:marks) next=std::max(next,m.id+1);
|
||||||
if(arrow.shape!=ScreenshotShape::None || arrow.isText()) return;
|
std::unordered_set<unsigned long long> used;
|
||||||
|
for(auto &m:marks) {
|
||||||
|
if(!m.id || !used.insert(m.id).second) { m.id=next++; used.insert(m.id); }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
POINT objectAnchor(const ScreenshotMark &m,const ScreenshotLink &link)
|
||||||
|
{
|
||||||
|
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||||
|
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||||
|
POINT p{LONG(std::lround(r.left+(r.right-r.left)*link.u)),LONG(std::lround(r.top+(r.bottom-r.top)*link.v))};
|
||||||
|
return m.isText()?rotateTextPoint(m,p):p;
|
||||||
|
}
|
||||||
|
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||||
|
double along=std::clamp(link.u,0.0,1.0)*segments;
|
||||||
|
size_t i=std::min(size_t(along),segments-1); double t=along-i;
|
||||||
|
POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to;
|
||||||
|
return {LONG(std::lround(a.x+(b.x-a.x)*t)),LONG(std::lround(a.y+(b.y-a.y)*t))};
|
||||||
|
}
|
||||||
|
bool findObjectAnchor(const ScreenshotMark &m,POINT drop,double tolerance,ScreenshotLink &link)
|
||||||
|
{
|
||||||
|
link={m.id,0,0};
|
||||||
|
if(m.isText() || m.shape!=ScreenshotShape::None) {
|
||||||
|
auto r=m.isText()?plainTextBounds(m):region(m.from,m.to);
|
||||||
|
POINT p=m.isText()?rotateTextPoint(m,drop,-1):drop;
|
||||||
|
double w=std::max(1L,r.right-r.left),h=std::max(1L,r.bottom-r.top);
|
||||||
|
double x=(p.x-r.left)/w,y=(p.y-r.top)/h;
|
||||||
|
bool inside=x>=0 && x<=1 && y>=0 && y<=1;
|
||||||
|
if(m.shape==ScreenshotShape::Circle || m.shape==ScreenshotShape::Ellipse) {
|
||||||
|
double dx=2*x-1,dy=2*y-1,length=std::hypot(dx,dy);
|
||||||
|
inside=length<=1;
|
||||||
|
if(length<0.001) { dx=1; dy=0; length=1; }
|
||||||
|
link.u=(1+dx/length)/2; link.v=(1+dy/length)/2;
|
||||||
|
} else {
|
||||||
|
link.u=std::clamp(x,0.0,1.0); link.v=std::clamp(y,0.0,1.0);
|
||||||
|
if(inside) {
|
||||||
|
double edges[]{x*w,(1-x)*w,y*h,(1-y)*h};
|
||||||
|
auto side=std::min_element(edges,edges+4)-edges;
|
||||||
|
if(side<2) link.u=side==0?0:1; else link.v=side==2?0:1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
auto anchor=objectAnchor(m,link);
|
||||||
|
return inside || std::hypot(double(drop.x-anchor.x),double(drop.y-anchor.y))<=tolerance;
|
||||||
|
}
|
||||||
|
double best=1e100;
|
||||||
|
size_t segments=m.points.size()>1?m.points.size()-1:1;
|
||||||
|
for(size_t i=0;i<segments;++i) {
|
||||||
|
POINT a=m.points.size()>1?m.points[i]:m.from,b=m.points.size()>1?m.points[i+1]:m.to;
|
||||||
|
double dx=b.x-a.x,dy=b.y-a.y,length=dx*dx+dy*dy;
|
||||||
|
double t=length?std::clamp(((drop.x-a.x)*dx+(drop.y-a.y)*dy)/length,0.0,1.0):0;
|
||||||
|
double distance=std::hypot(drop.x-a.x-t*dx,drop.y-a.y-t*dy);
|
||||||
|
if(distance<best) { best=distance; link.u=(i+t)/segments; }
|
||||||
|
}
|
||||||
|
return best<=tolerance+m.thickness/2.0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
void linkScreenshotEndpoint(ScreenshotMark &arrow, bool start, POINT drop,
|
||||||
|
std::vector<ScreenshotMark> &marks, double tolerance)
|
||||||
|
{
|
||||||
|
assignMarkIds(marks);
|
||||||
|
auto &number=start?arrow.fromNumber:arrow.toNumber;
|
||||||
|
auto &link=start?arrow.fromLink:arrow.toLink;
|
||||||
|
number=0; link={};
|
||||||
|
if(!arrow.isLine()) return;
|
||||||
|
// Number badges are drawn above other objects.
|
||||||
for(auto it=marks.rbegin();it!=marks.rend();++it)
|
for(auto it=marks.rbegin();it!=marks.rend();++it)
|
||||||
if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance)
|
if(it->number && std::hypot(double(drop.x-it->from.x),double(drop.y-it->from.y))<=it->radius+tolerance) {
|
||||||
{
|
number=it->number; return;
|
||||||
link=it->number;
|
}
|
||||||
break;
|
auto wouldCycle=[&](unsigned long long id) {
|
||||||
|
std::vector<unsigned long long> pending{id}; std::unordered_set<unsigned long long> seen;
|
||||||
|
while(!pending.empty()) {
|
||||||
|
auto current=pending.back(); pending.pop_back();
|
||||||
|
if(arrow.id && current==arrow.id) return true;
|
||||||
|
if(!current || !seen.insert(current).second) continue;
|
||||||
|
for(const auto &m:marks) if(m.id==current) {
|
||||||
|
pending.push_back(m.fromLink.target); pending.push_back(m.toLink.target); break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
for(auto it=marks.rbegin();it!=marks.rend();++it) {
|
||||||
|
if(it->number || &*it==&arrow || (arrow.id && it->id==arrow.id)) continue;
|
||||||
|
ScreenshotLink candidate;
|
||||||
|
if(findObjectAnchor(*it,drop,tolerance,candidate) && !wouldCycle(it->id)) { link=candidate; return; }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
||||||
{
|
{
|
||||||
|
assignMarkIds(marks);
|
||||||
auto badge=[&](int number) -> const ScreenshotMark * {
|
auto badge=[&](int number) -> const ScreenshotMark * {
|
||||||
if(number) for(const auto &m:marks) if(m.number==number) return &m;
|
if(number) for(const auto &m:marks) if(m.number==number) return &m;
|
||||||
return nullptr;
|
return nullptr;
|
||||||
@@ -1392,9 +1490,21 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
|||||||
if(length<0.001) { dx=start?1:-1; dy=0; length=1; }
|
if(length<0.001) { dx=start?1:-1; dy=0; length=1; }
|
||||||
return {m.from.x+LONG(std::lround(m.radius*dx/length)),m.from.y+LONG(std::lround(m.radius*dy/length))};
|
return {m.from.x+LONG(std::lround(m.radius*dx/length)),m.from.y+LONG(std::lround(m.radius*dy/length))};
|
||||||
};
|
};
|
||||||
for(auto &m:marks)
|
std::vector<int> state(marks.size());
|
||||||
{
|
std::function<void(size_t)> resolve=[&](size_t index) {
|
||||||
if(m.number || m.isHighlight() || m.isText() || m.shape!=ScreenshotShape::None) continue;
|
if(state[index]) return;
|
||||||
|
state[index]=1;
|
||||||
|
auto &m=marks[index];
|
||||||
|
if(m.isLine()) {
|
||||||
|
auto attach=[&](ScreenshotLink &link,POINT &point) {
|
||||||
|
if(!link.target) return;
|
||||||
|
for(size_t i=0;i<marks.size();++i) if(marks[i].id==link.target) {
|
||||||
|
if(state[i]==1) { link={}; return; } // Reject cyclic dependencies.
|
||||||
|
resolve(i); point=objectAnchor(marks[i],link); return;
|
||||||
|
}
|
||||||
|
link={}; // Deleted targets leave endpoints at their last position.
|
||||||
|
};
|
||||||
|
attach(m.fromLink,m.from); attach(m.toLink,m.to);
|
||||||
auto from=badge(m.fromNumber),to=badge(m.toNumber);
|
auto from=badge(m.fromNumber),to=badge(m.toNumber);
|
||||||
if(!from) m.fromNumber=0;
|
if(!from) m.fromNumber=0;
|
||||||
if(!to) m.toNumber=0;
|
if(!to) m.toNumber=0;
|
||||||
@@ -1403,6 +1513,9 @@ void resolveScreenshotLinks(std::vector<ScreenshotMark> &marks)
|
|||||||
if(to) m.to=rim(*to,start,false);
|
if(to) m.to=rim(*to,start,false);
|
||||||
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
|
if(m.freehand && !m.points.empty()) { m.points.front()=m.from; m.points.back()=m.to; }
|
||||||
}
|
}
|
||||||
|
state[index]=2;
|
||||||
|
};
|
||||||
|
for(size_t i=0;i<marks.size();++i) resolve(i);
|
||||||
}
|
}
|
||||||
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
MarkPart hitScreenshotMark(const ScreenshotMark &m, POINT p, double tolerance)
|
||||||
{
|
{
|
||||||
@@ -1470,6 +1583,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
POINT position, int width, int height, bool snapRotation)
|
POINT position, int width, int height, bool snapRotation)
|
||||||
{
|
{
|
||||||
auto m=original;
|
auto m=original;
|
||||||
|
if(position.x==origin.x && position.y==origin.y) return m;
|
||||||
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
if(part==MarkPart::Rotation && (m.isText() || (m.isLine() && !m.label.empty()))) {
|
||||||
if(position.x==origin.x && position.y==origin.y) return m;
|
if(position.x==origin.x && position.y==origin.y) return m;
|
||||||
auto text=m.isText()?m:screenshotArrowLabel(m);
|
auto text=m.isText()?m:screenshotArrowLabel(m);
|
||||||
@@ -1505,6 +1619,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
else if(part==MarkPart::Body)
|
else if(part==MarkPart::Body)
|
||||||
{
|
{
|
||||||
m.fromNumber=m.toNumber=0;
|
m.fromNumber=m.toNumber=0;
|
||||||
|
m.fromLink={}; m.toLink={};
|
||||||
auto shift=[&](LONG delta,LONG first,LONG last,int extent) {
|
auto shift=[&](LONG delta,LONG first,LONG last,int extent) {
|
||||||
LONG low=-first,high=LONG(extent-1)-last;
|
LONG low=-first,high=LONG(extent-1)-last;
|
||||||
if(low>high && m.isText()) {
|
if(low>high && m.isText()) {
|
||||||
@@ -1524,6 +1639,7 @@ ScreenshotMark dragScreenshotMark(const ScreenshotMark &original, MarkPart part,
|
|||||||
else if(part==MarkPart::Start || part==MarkPart::End)
|
else if(part==MarkPart::Start || part==MarkPart::End)
|
||||||
{
|
{
|
||||||
(part==MarkPart::Start?m.fromNumber:m.toNumber)=0;
|
(part==MarkPart::Start?m.fromNumber:m.toNumber)=0;
|
||||||
|
(part==MarkPart::Start?m.fromLink:m.toLink)={};
|
||||||
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
auto &endpoint=part==MarkPart::Start?m.from:m.to;
|
||||||
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
|
endpoint.x=std::clamp(endpoint.x+position.x-origin.x,0L,LONG(width-1));
|
||||||
endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1));
|
endpoint.y=std::clamp(endpoint.y+position.y-origin.y,0L,LONG(height-1));
|
||||||
|
|||||||
+7
-1
@@ -17,6 +17,10 @@ enum class LineStyle { Solid, Dotted, Dashed, DashDot };
|
|||||||
enum class LineEnds { End, Start, Both, None };
|
enum class LineEnds { End, Start, Both, None };
|
||||||
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
enum class ScreenshotShape { None, Rectangle, Circle, Ellipse };
|
||||||
struct ScreenshotOffset { double x, y; };
|
struct ScreenshotOffset { double x, y; };
|
||||||
|
struct ScreenshotLink {
|
||||||
|
unsigned long long target{};
|
||||||
|
double u{}, v{}; // Relative boundary position, or fractional position along a path.
|
||||||
|
};
|
||||||
struct ScreenshotMark {
|
struct ScreenshotMark {
|
||||||
POINT from, to; int number; int radius = 15; int thickness = 3;
|
POINT from, to; int number; int radius = 15; int thickness = 3;
|
||||||
int fromNumber = 0, toNumber = 0;
|
int fromNumber = 0, toNumber = 0;
|
||||||
@@ -33,6 +37,8 @@ struct ScreenshotMark {
|
|||||||
std::wstring label;
|
std::wstring label;
|
||||||
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
|
ScreenshotOffset labelOffset{0,-24}; // Along the line and perpendicular to it, in image pixels.
|
||||||
bool labelRotationManual=false;
|
bool labelRotationManual=false;
|
||||||
|
unsigned long long id{};
|
||||||
|
ScreenshotLink fromLink, toLink;
|
||||||
bool isText() const { return !text.empty(); }
|
bool isText() const { return !text.empty(); }
|
||||||
bool isHighlight() const { return !points.empty() && !freehand; }
|
bool isHighlight() const { return !points.empty() && !freehand; }
|
||||||
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
bool isLine() const { return !number && !isText() && !isHighlight() && shape==ScreenshotShape::None; }
|
||||||
@@ -48,7 +54,7 @@ class ScreenshotHistory {
|
|||||||
};
|
};
|
||||||
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
void resolveScreenshotLinks(std::vector<ScreenshotMark> &);
|
||||||
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
void linkScreenshotEndpoint(ScreenshotMark &, bool start, POINT drop,
|
||||||
const std::vector<ScreenshotMark> &, double tolerance);
|
std::vector<ScreenshotMark> &, double tolerance);
|
||||||
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
enum class MarkPart { None, Body, Start, End, Radius, Thickness, Rotation, Label };
|
||||||
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
POINT screenshotThicknessHandle(const ScreenshotMark &, double clearance = 16);
|
||||||
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
ScreenshotMark cloneScreenshotMark(const ScreenshotMark &, const std::vector<ScreenshotMark> &, int width, int height);
|
||||||
|
|||||||
+1
-1
@@ -2,7 +2,7 @@
|
|||||||
#include "platform.h"
|
#include "platform.h"
|
||||||
namespace ks
|
namespace ks
|
||||||
{
|
{
|
||||||
inline constexpr wchar_t AppVersion[] = L"0.6.4";
|
inline constexpr wchar_t AppVersion[] = L"0.6.6";
|
||||||
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
inline constexpr UINT UpdateMessage = WM_APP + 7;
|
||||||
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
inline constexpr UINT UpdateCheckIntervalMs = 6 * 60 * 60 * 1000;
|
||||||
struct UpdateResult
|
struct UpdateResult
|
||||||
|
|||||||
@@ -179,6 +179,67 @@ int main(int argc, char **argv) {
|
|||||||
require(detached.fromNumber==0,"dropping away from badges remains detached");
|
require(detached.fromNumber==0,"dropping away from badges remains detached");
|
||||||
auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100);
|
auto translated=ks::dragScreenshotMark(linked[2],ks::MarkPart::Body,{80,35},{85,40},160,100);
|
||||||
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
|
require(translated.fromNumber==0 && translated.toNumber==0,"moving whole arrow detaches both endpoints");
|
||||||
|
for(auto shape : {ks::ScreenshotShape::Rectangle,ks::ScreenshotShape::Circle,ks::ScreenshotShape::Ellipse}) {
|
||||||
|
std::vector<ks::ScreenshotMark> objects={{{100,50},{160,110},0},{{10,80},{100,80},0},{{10,140},{55,80},0}};
|
||||||
|
objects[0].shape=shape;
|
||||||
|
ks::linkScreenshotEndpoint(objects[1],false,{100,80},objects,6);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].toLink.target==objects[0].id && objects[1].to.x==100,"arrow attaches to shape boundary");
|
||||||
|
ks::linkScreenshotEndpoint(objects[2],false,{55,80},objects,6);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[2].toLink.target==objects[1].id,"arrow attaches to another arrow body");
|
||||||
|
auto targetId=objects[0].id,arrowId=objects[1].id;
|
||||||
|
auto snapshot=objects; ks::ScreenshotHistory linksHistory; linksHistory.remember(objects);
|
||||||
|
// Reverse storage order so dependency resolution cannot rely on insertion order.
|
||||||
|
std::reverse(objects.begin(),objects.end());
|
||||||
|
objects[2]=ks::dragScreenshotMark(objects[2],ks::MarkPart::Body,{0,0},{20,20},400,300);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].to.x==120 && objects[1].to.y==100 && objects[0].to.x==65 && objects[0].to.y==90,
|
||||||
|
"shape-arrow-arrow chain updates in dependency order");
|
||||||
|
require(linksHistory.undo(objects) && objects[1].to.x==100 && objects[2].toLink.target==arrowId,
|
||||||
|
"undo restores geometry and generic links");
|
||||||
|
require(linksHistory.redo(objects) && objects[0].to.x==65,"redo restores moved chain");
|
||||||
|
auto resized=objects;
|
||||||
|
auto corner=resized[2].to;
|
||||||
|
resized[2]=ks::dragScreenshotMark(resized[2],ks::MarkPart::End,corner,{corner.x+20,corner.y+20},400,300);
|
||||||
|
ks::resolveScreenshotLinks(resized);
|
||||||
|
require(resized[1].to.x==120 && resized[1].to.y==110 && resized[0].to.y==95,
|
||||||
|
"resizing shapes updates both levels of attached arrows");
|
||||||
|
ks::linkScreenshotEndpoint(objects[1],true,objects[0].from,objects,6);
|
||||||
|
require(!objects[1].fromLink.target,"cyclic arrow links are rejected");
|
||||||
|
auto clone=ks::cloneScreenshotMark(objects[1],objects,400,300);
|
||||||
|
require(!clone.id && !clone.fromLink.target && !clone.toLink.target,"cloned connector has independent identity and no links");
|
||||||
|
auto endpoint=objects[1].to;
|
||||||
|
objects.erase(objects.begin()+2); ks::resolveScreenshotLinks(objects);
|
||||||
|
require(!objects[1].toLink.target && objects[1].to.x==endpoint.x && objects[0].toLink.target==arrowId,
|
||||||
|
"deleting shape detaches only its dependents without moving endpoints");
|
||||||
|
objects.push_back(snapshot[0]); objects.back().id=0; ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects.back().id!=targetId && !objects[1].toLink.target,"new objects cannot revive deleted links");
|
||||||
|
}
|
||||||
|
{
|
||||||
|
std::vector<ks::ScreenshotMark> objects={{{80,40},{80,40},0},{{10,40},{80,40},0}};
|
||||||
|
objects[0].text=L"Target"; objects[0].fontSize=20;
|
||||||
|
auto bounds=ks::screenshotTextBounds(objects[0]);
|
||||||
|
ks::linkScreenshotEndpoint(objects[1],false,{bounds.left,(bounds.top+bounds.bottom)/2},objects,6);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].toLink.target==objects[0].id,"arrow attaches to text block");
|
||||||
|
auto old=objects[1].to;
|
||||||
|
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{30,20},400,300);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].to.x==old.x+30 && objects[1].to.y==old.y+20,"text attachment follows movement");
|
||||||
|
objects[0].rotation=90; ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].to.x!=old.x+30 || objects[1].to.y!=old.y+20,"text attachment follows rotation");
|
||||||
|
}
|
||||||
|
for(bool freehand : {false,true}) {
|
||||||
|
std::vector<ks::ScreenshotMark> objects={{{80,60},{160,80},0},{{10,140},{110,60},0}};
|
||||||
|
objects[0].points={{80,60},{140,60},{160,80}}; objects[0].freehand=freehand;
|
||||||
|
ks::linkScreenshotEndpoint(objects[1],false,{110,60},objects,6);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].toLink.target==objects[0].id,"arrow attaches to freehand and highlight strokes");
|
||||||
|
objects[0]=ks::dragScreenshotMark(objects[0],ks::MarkPart::Body,{0,0},{20,20},400,300);
|
||||||
|
ks::resolveScreenshotLinks(objects);
|
||||||
|
require(objects[1].to.x==130 && objects[1].to.y==80,"stroke attachment follows the original segment");
|
||||||
|
}
|
||||||
auto lastPoint=linked[2].from;
|
auto lastPoint=linked[2].from;
|
||||||
linked.erase(linked.begin());
|
linked.erase(linked.begin());
|
||||||
ks::resolveScreenshotLinks(linked);
|
ks::resolveScreenshotLinks(linked);
|
||||||
|
|||||||
+89
-2
@@ -36,11 +36,11 @@ int main()
|
|||||||
RECT client{}; GetClientRect(window,&client);
|
RECT client{}; GetClientRect(window,&client);
|
||||||
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
require(c.view.bottom<=client.bottom-c.statusHeight,"image does not overlap status bar");
|
||||||
ks::updateStatus(c);
|
ks::updateStatus(c);
|
||||||
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"default tool hint is shown");
|
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"default tool hint is shown");
|
||||||
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
|
SendMessageW(GetDlgItem(window,13),WM_MOUSEMOVE,0,0);
|
||||||
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
|
require(c.statusText.find(L"Ctrl+C")!=std::wstring::npos,"button hover shows action shortcut");
|
||||||
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
|
SendMessageW(GetDlgItem(window,13),WM_MOUSELEAVE,0,0);
|
||||||
require(c.statusText.find(L"Freehand")!=std::wstring::npos,"button leave restores contextual hint");
|
require(c.statusText.find(L"Pointer")!=std::wstring::npos,"button leave restores contextual hint");
|
||||||
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
|
for(int id:ks::ToolIds) require(GetDlgItem(window,id)!=nullptr,"all toolbar buttons created");
|
||||||
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
|
require(GetDlgItem(window,34)!=nullptr && c.toolIcons[0][14]!=nullptr,"font icon created and loaded");
|
||||||
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
|
ks::Mark text{{20,20},{20,20},0}; text.fontSize=18;
|
||||||
@@ -120,11 +120,98 @@ int main()
|
|||||||
std::abs(std::hypot(double(c.marks[2].to.x-end.x),double(c.marks[2].to.y-end.y))-15)<1,
|
std::abs(std::hypot(double(c.marks[2].to.x-end.x),double(c.marks[2].to.y-end.y))-15)<1,
|
||||||
"connector endpoints follow both badge rims");
|
"connector endpoints follow both badge rims");
|
||||||
}
|
}
|
||||||
|
// Selecting a linked connector (also the first click of a double-click)
|
||||||
|
// must not detach it before opening its caption editor.
|
||||||
|
for(int entry : {0,1,2}) {
|
||||||
|
c.selected=-1;
|
||||||
|
POINT body{110,c.marks[0].from.y};
|
||||||
|
if(freehand) body={(c.marks[2].points[0].x+c.marks[2].points[1].x)/2,
|
||||||
|
(c.marks[2].points[0].y+c.marks[2].points[1].y)/2};
|
||||||
|
mouse(WM_LBUTTONDOWN,body); mouse(WM_LBUTTONUP,body);
|
||||||
|
require(c.selected==2 && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,
|
||||||
|
"clicking a connector without moving preserves both links");
|
||||||
|
if(entry==0) { mouse(WM_LBUTTONDBLCLK,body); mouse(WM_LBUTTONUP,body); }
|
||||||
|
else if(entry==1) SendMessageW(window,WM_KEYDOWN,VK_F2,0);
|
||||||
|
else SendMessageW(window,WM_COMMAND,35,0);
|
||||||
|
require(c.inlineText && c.editingLabel,"linked connector caption opens through every entry point");
|
||||||
|
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Linked caption");
|
||||||
|
ks::finishInlineText(window,c,true);
|
||||||
|
require(c.marks[2].label==L"Linked caption" && c.marks[2].fromNumber==1 && c.marks[2].toNumber==2,
|
||||||
|
"adding or editing a caption preserves both connector links");
|
||||||
|
}
|
||||||
c.selected=2;
|
c.selected=2;
|
||||||
require(ks::markAt(c,c.marks[1].from).first==1,"selected connector cannot steal a badge click");
|
require(ks::markAt(c,c.marks[1].from).first==1,"selected connector cannot steal a badge click");
|
||||||
auto outside=c.marks[2].to; outside.x-=4;
|
auto outside=c.marks[2].to; outside.x-=4;
|
||||||
require(ks::markAt(c,outside)==std::pair{2,ks::MarkPart::End},"endpoint remains draggable outside badge");
|
require(ks::markAt(c,outside)==std::pair{2,ks::MarkPart::End},"endpoint remains draggable outside badge");
|
||||||
}
|
}
|
||||||
|
{
|
||||||
|
auto mouse=[&](UINT message,POINT p) {
|
||||||
|
LONG x=c.view.left+MulDiv(p.x,c.view.right-c.view.left,c.image->width);
|
||||||
|
LONG y=c.view.top+MulDiv(p.y,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,message,message==WM_LBUTTONUP?0:MK_LBUTTON,MAKELPARAM(x,y));
|
||||||
|
};
|
||||||
|
SendMessageW(window,WM_COMMAND,27,0); c.marks.clear();
|
||||||
|
mouse(WM_LBUTTONDOWN,{40,160}); mouse(WM_MOUSEMOVE,{100,190}); mouse(WM_LBUTTONUP,{100,190});
|
||||||
|
require(c.marks.empty() && !c.dragging && c.selected==-1,"pointer drag on empty canvas never adds an object");
|
||||||
|
for(int tool : {11,22}) for(int target : {0,1,2}) {
|
||||||
|
if(target==0) c.marks={{{55,160},{55,160},1},{{255,160},{255,160},2}};
|
||||||
|
else {
|
||||||
|
c.marks={{{30,140},{80,190},0},{{230,140},{280,190},0}};
|
||||||
|
if(target==1) for(auto &mark:c.marks) mark.shape=ks::ScreenshotShape::Rectangle;
|
||||||
|
else for(auto &mark:c.marks) mark.from.y=mark.to.y=160;
|
||||||
|
}
|
||||||
|
SendMessageW(window,WM_COMMAND,tool,0);
|
||||||
|
BYTE savedKeys[256]{},drawingKeys[256]{};
|
||||||
|
require(GetKeyboardState(savedKeys)!=FALSE,"read thread keyboard state");
|
||||||
|
std::copy(savedKeys,savedKeys+256,drawingKeys); drawingKeys[VK_CONTROL]=0x80;
|
||||||
|
require(SetKeyboardState(drawingKeys)!=FALSE,"set thread-local Ctrl for drawing test");
|
||||||
|
mouse(WM_LBUTTONDOWN,{55,160}); mouse(WM_MOUSEMOVE,{255,160}); mouse(WM_LBUTTONUP,{255,160});
|
||||||
|
SetKeyboardState(savedKeys);
|
||||||
|
require(c.marks.size()==3,"drawing over existing objects creates a connector with Ctrl");
|
||||||
|
auto &connector=c.marks[2];
|
||||||
|
require(target==0?(connector.fromNumber==1 && connector.toNumber==2):
|
||||||
|
(connector.fromLink.target==c.marks[0].id && connector.toLink.target==c.marks[1].id),
|
||||||
|
"new arrow or line automatically attaches both endpoints to targets");
|
||||||
|
require(connector.ends==(tool==11?c.arrowEnds:ks::LineEnds::None),"arrow and straight-line tools retain their end styles");
|
||||||
|
auto before=connector.from;
|
||||||
|
SendMessageW(window,WM_KEYDOWN,'V',0);
|
||||||
|
require(c.pointer && !c.arrow && !c.freehand,"V selects pointer mode");
|
||||||
|
POINT body=target==0?POINT{55,160}:target==1?POINT{30,175}:POINT{30,160};
|
||||||
|
mouse(WM_LBUTTONDOWN,body); mouse(WM_MOUSEMOVE,{body.x,body.y-20}); mouse(WM_LBUTTONUP,{body.x,body.y-20});
|
||||||
|
require(c.selected==0 && c.marks.size()==3 && c.marks[2].from.y<before.y,
|
||||||
|
"pointer moves the target and the new connector follows without adding objects");
|
||||||
|
mouse(WM_LBUTTONDOWN,{150,220}); mouse(WM_LBUTTONUP,{150,220});
|
||||||
|
require(c.selected==-1 && c.marks.size()==3,"pointer click on empty space only clears selection");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for(bool manualAngle : {false,true}) {
|
||||||
|
c.marks={{{40,140},{180,60},0}}; c.selected=0;
|
||||||
|
c.marks[0].fontSize=18; c.marks[0].fontWeight=FW_BOLD;
|
||||||
|
c.marks[0].rotation=31; c.marks[0].labelRotationManual=manualAngle;
|
||||||
|
SendMessageW(window,WM_COMMAND,35,0);
|
||||||
|
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Sloped caption");
|
||||||
|
ks::finishInlineText(window,c,true);
|
||||||
|
auto captionAngle=ks::screenshotArrowLabel(c.marks[0]).rotation;
|
||||||
|
require(captionAngle!=0,"caption retains its automatic or manual angle");
|
||||||
|
SendMessageW(window,WM_COMMAND,26,0);
|
||||||
|
LONG x=c.view.left+MulDiv(220,c.view.right-c.view.left,c.image->width);
|
||||||
|
LONG y=c.view.top+MulDiv(190,c.view.bottom-c.view.top,c.image->height);
|
||||||
|
SendMessageW(window,WM_LBUTTONDOWN,MK_LBUTTON,MAKELPARAM(x,y));
|
||||||
|
require(c.inlineText && !c.editingLabel,"standalone text starts independently of caption");
|
||||||
|
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Independent text");
|
||||||
|
ks::finishInlineText(window,c,true);
|
||||||
|
require(c.marks.size()==2 && c.marks[1].isText() && c.marks[1].rotation==0,
|
||||||
|
"new standalone text does not inherit caption rotation");
|
||||||
|
require(c.marks[1].fontSize==18 && c.marks[1].fontWeight==FW_BOLD,
|
||||||
|
"new text still inherits font styling");
|
||||||
|
require(ks::screenshotArrowLabel(c.marks[0]).rotation==captionAngle,"new text does not change caption angle");
|
||||||
|
c.marks[1].rotation=22; c.selected=1;
|
||||||
|
SendMessageW(window,WM_KEYDOWN,VK_F2,0);
|
||||||
|
SendMessageW(c.inlineText,EM_REPLACESEL,TRUE,(LPARAM)L"Edited standalone text");
|
||||||
|
ks::finishInlineText(window,c,true);
|
||||||
|
require(c.marks[1].rotation==22,"editing existing rotated text preserves its own angle");
|
||||||
|
SendMessageW(window,WM_COMMAND,27,0);
|
||||||
|
}
|
||||||
std::vector<ks::Mark> nudgeMarks;
|
std::vector<ks::Mark> nudgeMarks;
|
||||||
nudgeMarks.push_back({{60,60},{60,60},1});
|
nudgeMarks.push_back({{60,60},{60,60},1});
|
||||||
nudgeMarks.push_back({{60,60},{120,100},0});
|
nudgeMarks.push_back({{60,60},{120,100},0});
|
||||||
|
|||||||
Reference in New Issue
Block a user