dsh-agent-team-ui-testing 0.1.2 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -2
- package/client.js +767 -33
- package/docs/capture-p0.md +1 -1
- package/docs/getting-started.md +2 -2
- package/docs/images/tutorial-01-overview.png +0 -0
- package/docs/images/tutorial-02-history-dropdown.png +0 -0
- package/docs/images/tutorial-03-pick-element.png +0 -0
- package/docs/images/tutorial-04-annotate.png +0 -0
- package/docs/index.md +2 -1
- package/docs/tutorial.md +126 -0
- package/docs/verification.md +2 -0
- package/docs/viewport-control.md +6 -2
- package/package.json +2 -2
- package/skills/ui-testing-web/SKILL.md +5 -5
- package/skills/ui-testing-web/references/scenarios/annotations.md +7 -1
package/client.js
CHANGED
|
@@ -10,7 +10,7 @@ window.__ModuleLoader__.load({
|
|
|
10
10
|
|
|
11
11
|
const ID = 'ui-testing-browser';
|
|
12
12
|
/** 面板构建标识:刷新后用它确认浏览器里跑的是哪一份 client.js(排查「改了没生效」)。 */
|
|
13
|
-
const CLIENT_BUILD = '2026-11-
|
|
13
|
+
const CLIENT_BUILD = '2026-11-11T18:50+08:00·address-history-per-workspace';
|
|
14
14
|
const KIND = 'debug-view';
|
|
15
15
|
const TITLE = '调试视图';
|
|
16
16
|
/** 可选运行环境;本期只有 Web 视图可用,其余置灰。 */
|
|
@@ -62,7 +62,12 @@ window.__ModuleLoader__.load({
|
|
|
62
62
|
* real implementations rather than a copy.
|
|
63
63
|
*/
|
|
64
64
|
const ADDRESS_HISTORY_LIMIT = 7;
|
|
65
|
-
const ADDRESS_HISTORY_KEY = 'dsh-agent-team-ui-testing:address-history:
|
|
65
|
+
const ADDRESS_HISTORY_KEY = 'dsh-agent-team-ui-testing:address-history:v2';
|
|
66
|
+
/** One bucket per workspace scope: two workspaces never read each other's history. */
|
|
67
|
+
function addressHistoryStorageKey(scopeId) {
|
|
68
|
+
const clean = typeof scopeId === 'string' ? scopeId.trim().slice(0, 96).replace(/[^A-Za-z0-9_.:-]/gu, '') : '';
|
|
69
|
+
return ADDRESS_HISTORY_KEY + ':' + (clean || 'unscoped');
|
|
70
|
+
}
|
|
66
71
|
function addressHistoryNormalize(list, limit = ADDRESS_HISTORY_LIMIT) {
|
|
67
72
|
const out = [];
|
|
68
73
|
for (const item of Array.isArray(list) ? list : []) {
|
|
@@ -91,16 +96,17 @@ window.__ModuleLoader__.load({
|
|
|
91
96
|
function addressHistoryStorage() {
|
|
92
97
|
try { return typeof window !== 'undefined' && window.localStorage ? window.localStorage : null; } catch { return null; }
|
|
93
98
|
}
|
|
94
|
-
function addressHistoryRead(storage, limit = ADDRESS_HISTORY_LIMIT) {
|
|
99
|
+
function addressHistoryRead(storage, scopeId, limit = ADDRESS_HISTORY_LIMIT) {
|
|
95
100
|
try {
|
|
96
101
|
if (!storage) return [];
|
|
97
|
-
|
|
102
|
+
const key = addressHistoryStorageKey(scopeId);
|
|
103
|
+
return addressHistoryNormalize(JSON.parse(storage.getItem(key) || '[]'), limit);
|
|
98
104
|
} catch { return []; }
|
|
99
105
|
}
|
|
100
|
-
function addressHistoryWrite(storage, list) {
|
|
106
|
+
function addressHistoryWrite(storage, scopeId, list) {
|
|
101
107
|
try {
|
|
102
108
|
if (!storage) return false;
|
|
103
|
-
storage.setItem(
|
|
109
|
+
storage.setItem(addressHistoryStorageKey(scopeId), JSON.stringify(addressHistoryNormalize(list)));
|
|
104
110
|
return true;
|
|
105
111
|
} catch { return false; }
|
|
106
112
|
}
|
|
@@ -964,7 +970,18 @@ window.__ModuleLoader__.load({
|
|
|
964
970
|
if (tool === 'line' || tool === 'arrow' || tool === 'brush') return { fill: false, stroke: true };
|
|
965
971
|
return mode === 'fill' ? { fill: true, stroke: false } : { fill: false, stroke: true };
|
|
966
972
|
}
|
|
973
|
+
/**
|
|
974
|
+
* 选择类工具(v3,2026-11-12):只改面板本地选中态,不产生图形、不写 Host 文档。
|
|
975
|
+
* `select` 点选/拖动已选图形,`marquee` 用矩形框选多个;两者与绘制/橡皮擦/文字互斥。
|
|
976
|
+
*/
|
|
977
|
+
const ANNOT_SELECTION_TOOLS = Object.freeze(['select', 'marquee']);
|
|
978
|
+
function annotIsSelectionTool(tool) {
|
|
979
|
+
return ANNOT_SELECTION_TOOLS.includes(tool);
|
|
980
|
+
}
|
|
967
981
|
const ANNOT_TOOLS = Object.freeze([
|
|
982
|
+
// 选择/框选放在工具栏最左侧(用户 2026-11-12 要求),绘制工具的相对顺序不变。
|
|
983
|
+
Object.freeze({ id: 'select', label: '选择' }),
|
|
984
|
+
Object.freeze({ id: 'marquee', label: '框选' }),
|
|
968
985
|
Object.freeze({ id: 'circle', label: '圆形' }),
|
|
969
986
|
Object.freeze({ id: 'rect', label: '矩形' }),
|
|
970
987
|
Object.freeze({ id: 'line', label: '线段' }),
|
|
@@ -1469,6 +1486,20 @@ window.__ModuleLoader__.load({
|
|
|
1469
1486
|
/* v2:横栏右侧的可见文案按钮「清除画布」(§11.4)与状态栏同步提示(§11.3)。 */
|
|
1470
1487
|
.utb-annot-shot { width: auto; min-width: 0; height: 28px; padding: 0 10px; font-size: 12px; line-height: 1; white-space: nowrap; border-color: var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); }
|
|
1471
1488
|
.utb-annot-clear { width: auto; min-width: 0; height: 28px; padding: 0 10px; margin-left: auto; font-size: 12px; line-height: 1; white-space: nowrap; border-color: var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); }
|
|
1489
|
+
.utb-annot-delete { width: auto; min-width: 0; height: 28px; padding: 0 10px; font-size: 12px; line-height: 1; white-space: nowrap; border-color: var(--dsw-alias-border-l1); background: var(--dsw-alias-bg-base); }
|
|
1490
|
+
/* 选中态(选择/框选):品牌色光晕 + 半透明包围框 + 并行胶囊;纯面板 SVG 视觉,不进截图、不改图形数据。 */
|
|
1491
|
+
.utb-annot-select-halo { opacity: .4; pointer-events: none; }
|
|
1492
|
+
.utb-annot-select-halo * { stroke: #0b6bff !important; fill: none !important; stroke-width: 8 !important; vector-effect: non-scaling-stroke; }
|
|
1493
|
+
.utb-annot-select-halo polygon { fill: #0b6bff !important; stroke: #0b6bff !important; }
|
|
1494
|
+
.utb-annot-select-halo text { fill: #0b6bff !important; stroke: none !important; }
|
|
1495
|
+
.utb-annot-select-group { pointer-events: none; }
|
|
1496
|
+
.utb-annot-select-fill { fill: rgba(11, 107, 255, .16); stroke: none; pointer-events: none; }
|
|
1497
|
+
.utb-annot-select-edge { fill: none; stroke: #fff; stroke-width: 3; vector-effect: non-scaling-stroke; pointer-events: none; }
|
|
1498
|
+
.utb-annot-select-ants { fill: none; stroke: #0b6bff; stroke-width: 1.6; stroke-dasharray: 5 3; vector-effect: non-scaling-stroke; pointer-events: none; animation: utb-annot-ants 1s linear infinite; }
|
|
1499
|
+
@keyframes utb-annot-ants { to { stroke-dashoffset: -8; } }
|
|
1500
|
+
@media (prefers-reduced-motion: reduce) { .utb-annot-select-ants { animation: none; } }
|
|
1501
|
+
.utb-annot-selected-status { flex: 0 0 auto; color: var(--dsw-alias-brand-primary); font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
1502
|
+
.utb-annot-marquee-box { fill: color-mix(in srgb, var(--dsw-alias-brand-primary) 10%, transparent); stroke: var(--dsw-alias-brand-primary); stroke-width: 1; stroke-dasharray: 4 3; vector-effect: non-scaling-stroke; pointer-events: none; }
|
|
1472
1503
|
.utb-annot-sync { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--dsw-alias-state-warn-primary); }
|
|
1473
1504
|
.utb-annot-layer { display: block; overflow: hidden; }
|
|
1474
1505
|
.utb-annot-layer:focus-visible { outline: 2px solid var(--dsw-alias-brand-primary); outline-offset: -2px; }
|
|
@@ -1572,6 +1603,10 @@ window.__ModuleLoader__.load({
|
|
|
1572
1603
|
/** 标注子工具的小图标(16×16、currentColor、stroke 1.7)。 */
|
|
1573
1604
|
function IconAnnotTool({ tool }) {
|
|
1574
1605
|
const svg = (children) => h('svg', { width: 16, height: 16, viewBox: '0 0 24 24', fill: 'none', stroke: 'currentColor', strokeWidth: 1.7, strokeLinecap: 'round', strokeLinejoin: 'round', 'aria-hidden': true }, children);
|
|
1606
|
+
// 选择:鼠标指针箭头(白芯描边,和十字形的绘制工具区分开)。
|
|
1607
|
+
if (tool === 'select') return svg(h('path', { d: 'M6.2 3.8v14.2l3.8-3.5 2.5 5.2 2.6-1.2-2.5-5.2 5-.5Z' }));
|
|
1608
|
+
// 框选:虚线矩形(与实线「矩形」绘制工具同尺寸,靠虚线区分)。
|
|
1609
|
+
if (tool === 'marquee') return svg(h('rect', { x: 4.5, y: 5.5, width: 15, height: 13, rx: 1.2, strokeDasharray: '2.6 2.4' }));
|
|
1575
1610
|
if (tool === 'circle') return svg(h('circle', { cx: 12, cy: 12, r: 7 }));
|
|
1576
1611
|
if (tool === 'rect') return svg(h('rect', { x: 4.5, y: 5.5, width: 15, height: 13, rx: 1.2 }));
|
|
1577
1612
|
if (tool === 'line') return svg(h('path', { d: 'M5.5 18.5 18.5 5.5' }));
|
|
@@ -2547,6 +2582,13 @@ window.__ModuleLoader__.load({
|
|
|
2547
2582
|
// Mirrors the capability fact for callbacks that must not read render closures.
|
|
2548
2583
|
const annotChannelRef = React.useRef(false);
|
|
2549
2584
|
const annotEraseRef = React.useRef({ active: false, pointerId: -1, map: null, last: null, changed: 0 });
|
|
2585
|
+
// 选择/框选(v3):选中集合只用图形 id 表达;框选与拖动各有一条互斥手势。
|
|
2586
|
+
const annotSelectionRef = React.useRef(new Set());
|
|
2587
|
+
const annotSelectionRafRef = React.useRef(0);
|
|
2588
|
+
const annotMarqueeRef = React.useRef({ active: false, pointerId: -1, map: null, start: null, end: null, node: null });
|
|
2589
|
+
const annotMoveRef = React.useRef({ active: false, pointerId: -1, map: null, start: null, last: null, origin: null, changed: false });
|
|
2590
|
+
/** Hover cursor for the select tool: 'move' over a movable shape/selection box, else 'default'. */
|
|
2591
|
+
const annotHoverRef = React.useRef('default');
|
|
2550
2592
|
const annotHitCtxRef = React.useRef(null);
|
|
2551
2593
|
// ---- 选元素发送到聊天框 ----------------------------------------------------------------
|
|
2552
2594
|
// 同步真值在 pickRef(模式闸),React 状态只镜像渲染;悬浮命中 80ms 节流 + 单飞,
|
|
@@ -3087,18 +3129,26 @@ window.__ModuleLoader__.load({
|
|
|
3087
3129
|
* addresses, newest first. Recorded from the *accepted* address, so a redirect or an Agent
|
|
3088
3130
|
* navigation is stored as the page that actually loaded, and a failed navigation is not.
|
|
3089
3131
|
* -------------------------------------------------------------------------------------- */
|
|
3090
|
-
|
|
3132
|
+
// History is per workspace: the key carries the runtime's opaque workspace scope id, so a
|
|
3133
|
+
// second workspace never sees (or inherits) another workspace's addresses. An unresolved
|
|
3134
|
+
// scope reads nothing and never writes, instead of falling back to a shared bucket.
|
|
3135
|
+
const historyScopeId = ui && ui.scope && ui.scope.id ? String(ui.scope.id) : '';
|
|
3136
|
+
const [addressHistory, setAddressHistory] = React.useState(() => addressHistoryRead(addressHistoryStorage(), historyScopeId));
|
|
3091
3137
|
const [historyOpen, setHistoryOpen] = React.useState(false);
|
|
3092
3138
|
const historyAddress = acceptedAddress();
|
|
3093
3139
|
React.useEffect(() => {
|
|
3140
|
+
setAddressHistory(addressHistoryRead(addressHistoryStorage(), historyScopeId));
|
|
3141
|
+
}, [historyScopeId]);
|
|
3142
|
+
React.useEffect(() => {
|
|
3143
|
+
if (!historyScopeId) return;
|
|
3094
3144
|
if (!/^https?:\/\//i.test(historyAddress)) return;
|
|
3095
3145
|
const storage = addressHistoryStorage();
|
|
3096
|
-
const current = addressHistoryRead(storage);
|
|
3146
|
+
const current = addressHistoryRead(storage, historyScopeId);
|
|
3097
3147
|
const next = addressHistoryPush(current, historyAddress);
|
|
3098
3148
|
if (next.length === current.length && next[0] === current[0]) return;
|
|
3099
|
-
addressHistoryWrite(storage, next);
|
|
3149
|
+
addressHistoryWrite(storage, historyScopeId, next);
|
|
3100
3150
|
setAddressHistory(next);
|
|
3101
|
-
}, [historyAddress]);
|
|
3151
|
+
}, [historyAddress, historyScopeId]);
|
|
3102
3152
|
// The dropdown is a local popover: it never survives a mode switch or a scope change.
|
|
3103
3153
|
React.useEffect(() => {
|
|
3104
3154
|
if (!historyOpen) return undefined;
|
|
@@ -3986,6 +4036,7 @@ window.__ModuleLoader__.load({
|
|
|
3986
4036
|
if (match) maxId = Math.max(maxId, Number(match[1]));
|
|
3987
4037
|
}
|
|
3988
4038
|
annotShapesRef.current = imported;
|
|
4039
|
+
annotPruneSelection();
|
|
3989
4040
|
const safeWatermark = Number.isSafeInteger(previousWatermark) && previousWatermark >= 0 ? previousWatermark : 0;
|
|
3990
4041
|
const safeMaxId = Number.isSafeInteger(maxId) && maxId >= 0 ? maxId : 0;
|
|
3991
4042
|
annotNextIdRef.current = Math.max(safeWatermark, safeMaxId);
|
|
@@ -4082,7 +4133,10 @@ window.__ModuleLoader__.load({
|
|
|
4082
4133
|
if (snapshot.contentSeq === annotContentSeqRef.current) {
|
|
4083
4134
|
// The final ACK's normalized document (including Host truncation) is the visible truth.
|
|
4084
4135
|
if (accepted && Array.isArray(accepted.shapes)) {
|
|
4085
|
-
|
|
4136
|
+
// An ACK for the previous edit must not replace the geometry under a held pointer.
|
|
4137
|
+
// Reconcile Host normalization/truncation when this move finishes or is cancelled.
|
|
4138
|
+
if (annotMoveRef.current.active) annotMoveRef.current.acceptedShapes = accepted.shapes;
|
|
4139
|
+
else annotApplyImported(accepted.shapes);
|
|
4086
4140
|
annotDirtyRef.current = false;
|
|
4087
4141
|
}
|
|
4088
4142
|
if (revision !== null) annotShownRevisionRef.current = Math.max(annotShownRevisionRef.current, revision);
|
|
@@ -4175,6 +4229,8 @@ window.__ModuleLoader__.load({
|
|
|
4175
4229
|
|
|
4176
4230
|
/** Freeze identity + normalized shapes and enqueue as the newest snapshot (at most 1 waiting). */
|
|
4177
4231
|
function annotPublishNow() {
|
|
4232
|
+
// An older edit's debounce may fire during a new move. Only publish committed geometry.
|
|
4233
|
+
if (annotMoveRef.current.active) return null;
|
|
4178
4234
|
const identity = annotIdentityNow();
|
|
4179
4235
|
if (!identity) return null;
|
|
4180
4236
|
const normalized = annotNormalize();
|
|
@@ -4205,6 +4261,8 @@ window.__ModuleLoader__.load({
|
|
|
4205
4261
|
* (capability temporarily unavailable). It is never converted into an empty document.
|
|
4206
4262
|
*/
|
|
4207
4263
|
function annotRetainDirty(flush) {
|
|
4264
|
+
// Retained content is committed content; a scope/runtime change cancels a held move first.
|
|
4265
|
+
cancelAnnotMove();
|
|
4208
4266
|
if (!annotDirtyRef.current) return;
|
|
4209
4267
|
const normalized = annotNormalize();
|
|
4210
4268
|
const identity = annotIdentityNow({ anyEpoch: true });
|
|
@@ -4245,6 +4303,7 @@ window.__ModuleLoader__.load({
|
|
|
4245
4303
|
resetAnnotErase();
|
|
4246
4304
|
setAnnotBusy(false);
|
|
4247
4305
|
annotShapesRef.current = [];
|
|
4306
|
+
annotSelectionRef.current = new Set();
|
|
4248
4307
|
annotCandidateRef.current = null;
|
|
4249
4308
|
setAnnotVersion((value) => value + 1);
|
|
4250
4309
|
annotDirtyRef.current = false;
|
|
@@ -4291,6 +4350,7 @@ window.__ModuleLoader__.load({
|
|
|
4291
4350
|
resetAnnotErase();
|
|
4292
4351
|
setAnnotBusy(false);
|
|
4293
4352
|
annotShapesRef.current = [];
|
|
4353
|
+
annotSelectionRef.current = new Set();
|
|
4294
4354
|
annotCandidateRef.current = null;
|
|
4295
4355
|
setAnnotVersion((value) => value + 1);
|
|
4296
4356
|
annotDirtyRef.current = false;
|
|
@@ -4315,7 +4375,8 @@ window.__ModuleLoader__.load({
|
|
|
4315
4375
|
/** Clean + no gesture/edit/in-flight/error → a newer Host revision may replace the view. */
|
|
4316
4376
|
function annotCanAdopt() {
|
|
4317
4377
|
if (annotDirtyRef.current) return false;
|
|
4318
|
-
if (annotGestureRef.current.active || annotEditRef.current.active || annotEraseRef.current.active
|
|
4378
|
+
if (annotGestureRef.current.active || annotEditRef.current.active || annotEraseRef.current.active
|
|
4379
|
+
|| annotMoveRef.current.active || annotMarqueeRef.current.active) return false;
|
|
4319
4380
|
const entry = annotPublisherRef.current;
|
|
4320
4381
|
if (!entry) return false;
|
|
4321
4382
|
if (entry.inflight || entry.queued || entry.lastError) return false;
|
|
@@ -4501,7 +4562,7 @@ window.__ModuleLoader__.load({
|
|
|
4501
4562
|
}
|
|
4502
4563
|
|
|
4503
4564
|
/** Text uses the rendered `getBBox()` in canvas CSS px; unreadable bbox = no hit (no guessing). */
|
|
4504
|
-
function annotHitText(shape, point) {
|
|
4565
|
+
function annotHitText(shape, point, padding = 0) {
|
|
4505
4566
|
const svg = annotSvgRef.current;
|
|
4506
4567
|
if (!svg) return false;
|
|
4507
4568
|
const id = String(shape.id || '').replace(/["\\]/g, '');
|
|
@@ -4510,7 +4571,8 @@ window.__ModuleLoader__.load({
|
|
|
4510
4571
|
let box = null;
|
|
4511
4572
|
try { box = node.getBBox(); } catch { box = null; }
|
|
4512
4573
|
if (!box || !(box.width > 0) || !(box.height > 0)) return false;
|
|
4513
|
-
return point.x >= box.x && point.x <= box.x + box.width
|
|
4574
|
+
return point.x >= box.x - padding && point.x <= box.x + box.width + padding
|
|
4575
|
+
&& point.y >= box.y - padding && point.y <= box.y + box.height + padding;
|
|
4514
4576
|
}
|
|
4515
4577
|
|
|
4516
4578
|
function annotHitArrow(shape, point, radius) {
|
|
@@ -4520,11 +4582,17 @@ window.__ModuleLoader__.load({
|
|
|
4520
4582
|
return annotPointInTriangle(point, geometry.points);
|
|
4521
4583
|
}
|
|
4522
4584
|
|
|
4523
|
-
function annotHitsShape(shape, point) {
|
|
4524
|
-
const radius = annotEraseRadius(shape);
|
|
4585
|
+
function annotHitsShape(shape, point, hitRadius) {
|
|
4586
|
+
const radius = hitRadius === undefined ? annotEraseRadius(shape) : hitRadius;
|
|
4525
4587
|
if (shape.type === 'rect' || shape.type === 'circle') return annotHitPath(shape, point, radius);
|
|
4526
|
-
if (shape.type === 'text') return annotHitText(shape, point);
|
|
4527
|
-
if (shape.type === 'arrow')
|
|
4588
|
+
if (shape.type === 'text') return annotHitText(shape, point, hitRadius === undefined ? 0 : radius);
|
|
4589
|
+
if (shape.type === 'arrow') {
|
|
4590
|
+
if (annotHitArrow(shape, point, radius)) return true;
|
|
4591
|
+
if (hitRadius === undefined) return false;
|
|
4592
|
+
const geometry = annotArrowGeometry({ x: shape.x1, y: shape.y1 }, { x: shape.x2, y: shape.y2 }, shape.strokeWidth);
|
|
4593
|
+
return Boolean(geometry && geometry.points.some((a, index, points) =>
|
|
4594
|
+
annotSegmentDistance(point, a, points[(index + 1) % points.length]) <= radius));
|
|
4595
|
+
}
|
|
4528
4596
|
if (shape.type === 'line') {
|
|
4529
4597
|
return annotSegmentDistance(point, { x: shape.x1, y: shape.y1 }, { x: shape.x2, y: shape.y2 }) <= radius;
|
|
4530
4598
|
}
|
|
@@ -4548,6 +4616,413 @@ window.__ModuleLoader__.load({
|
|
|
4548
4616
|
annotEraseRef.current = { active: false, pointerId: -1, map: null, last: null, changed: 0 };
|
|
4549
4617
|
}
|
|
4550
4618
|
|
|
4619
|
+
/* ---- 选择 / 框选(v3)----------------------------------------------------------------
|
|
4620
|
+
* 选中集合只属于本面板:不写 Host 文档、不改变 SVG 图形的数据属性之外的任何事实。
|
|
4621
|
+
* 点选用屏幕空间容差命中最上层图形;框选以手势开始时的集合为基准实时预览。
|
|
4622
|
+
* 只有「移动」与「删除」会改图形并走既有的 150ms 合并上报;选中本身零上报。
|
|
4623
|
+
* ------------------------------------------------------------------------------------ */
|
|
4624
|
+
|
|
4625
|
+
function annotSelectionRefresh() {
|
|
4626
|
+
setAnnotVersion((value) => value + 1);
|
|
4627
|
+
}
|
|
4628
|
+
|
|
4629
|
+
/** Coalesce pointer-move re-renders to one per animation frame (move stays smooth). */
|
|
4630
|
+
function annotSelectionScheduleRender() {
|
|
4631
|
+
if (annotSelectionRafRef.current) return;
|
|
4632
|
+
annotSelectionRafRef.current = window.requestAnimationFrame(() => {
|
|
4633
|
+
annotSelectionRafRef.current = 0;
|
|
4634
|
+
annotSelectionRefresh();
|
|
4635
|
+
});
|
|
4636
|
+
}
|
|
4637
|
+
|
|
4638
|
+
function annotSelectionCount() {
|
|
4639
|
+
return annotSelectionRef.current.size;
|
|
4640
|
+
}
|
|
4641
|
+
|
|
4642
|
+
/** Click modifiers toggle; marquee modifiers use union/difference/intersection instead. */
|
|
4643
|
+
function annotSelectionMode(event) {
|
|
4644
|
+
if (event.shiftKey && event.altKey) return 'intersect';
|
|
4645
|
+
if (event.altKey) return 'subtract';
|
|
4646
|
+
if (event.shiftKey) return 'add';
|
|
4647
|
+
if (event.ctrlKey || event.metaKey) return 'toggle';
|
|
4648
|
+
return 'replace';
|
|
4649
|
+
}
|
|
4650
|
+
|
|
4651
|
+
function annotApplySelection(ids, mode, base, scheduled) {
|
|
4652
|
+
const existing = new Set(annotShapesRef.current.map((shape) => shape.id));
|
|
4653
|
+
const hits = new Set(ids.filter((id) => existing.has(id)));
|
|
4654
|
+
const next = mode === 'replace' ? new Set() : new Set([...base].filter((id) => existing.has(id)));
|
|
4655
|
+
if (mode === 'intersect') {
|
|
4656
|
+
for (const id of next) if (!hits.has(id)) next.delete(id);
|
|
4657
|
+
} else {
|
|
4658
|
+
for (const id of hits) {
|
|
4659
|
+
if (mode === 'subtract' || (mode === 'toggle' && next.has(id))) next.delete(id);
|
|
4660
|
+
else next.add(id);
|
|
4661
|
+
}
|
|
4662
|
+
}
|
|
4663
|
+
const previous = annotSelectionRef.current;
|
|
4664
|
+
if (next.size === previous.size && [...next].every((id) => previous.has(id))) return;
|
|
4665
|
+
annotSelectionRef.current = next;
|
|
4666
|
+
if (scheduled) annotSelectionScheduleRender();
|
|
4667
|
+
else annotSelectionRefresh();
|
|
4668
|
+
}
|
|
4669
|
+
|
|
4670
|
+
function annotSetSelection(ids, additive) {
|
|
4671
|
+
annotApplySelection(ids, additive ? 'toggle' : 'replace', annotSelectionRef.current, false);
|
|
4672
|
+
}
|
|
4673
|
+
|
|
4674
|
+
function annotClearSelection() {
|
|
4675
|
+
if (!annotSelectionRef.current.size) return;
|
|
4676
|
+
annotSelectionRef.current = new Set();
|
|
4677
|
+
annotSelectionRefresh();
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4680
|
+
/** Drop ids the current document no longer contains (import/erase/clear). */
|
|
4681
|
+
function annotPruneSelection() {
|
|
4682
|
+
if (!annotSelectionRef.current.size) return;
|
|
4683
|
+
const existing = new Set(annotShapesRef.current.map((shape) => shape.id));
|
|
4684
|
+
let changed = false;
|
|
4685
|
+
for (const id of Array.from(annotSelectionRef.current)) {
|
|
4686
|
+
if (!existing.has(id)) { annotSelectionRef.current.delete(id); changed = true; }
|
|
4687
|
+
}
|
|
4688
|
+
if (changed) annotSelectionRefresh();
|
|
4689
|
+
}
|
|
4690
|
+
|
|
4691
|
+
/** Analytic bbox in canvas CSS px (text uses the rendered getBBox, else a conservative estimate). */
|
|
4692
|
+
function annotShapeBounds(shape) {
|
|
4693
|
+
const move = annotMoveRef.current;
|
|
4694
|
+
const base = move.active && move.bounds && move.bounds.get(shape.id);
|
|
4695
|
+
if (base) return { x: base.x + move.dx, y: base.y + move.dy, w: base.w, h: base.h };
|
|
4696
|
+
const width = Number(shape.strokeWidth);
|
|
4697
|
+
const pad = (Number.isFinite(width) && width > 0 ? width : 1) / 2 + 1;
|
|
4698
|
+
if (shape.type === 'circle') {
|
|
4699
|
+
return { x: shape.cx - shape.rx - pad, y: shape.cy - shape.ry - pad, w: shape.rx * 2 + pad * 2, h: shape.ry * 2 + pad * 2 };
|
|
4700
|
+
}
|
|
4701
|
+
if (shape.type === 'rect') {
|
|
4702
|
+
return { x: shape.x - pad, y: shape.y - pad, w: shape.w + pad * 2, h: shape.h + pad * 2 };
|
|
4703
|
+
}
|
|
4704
|
+
if (shape.type === 'line' || shape.type === 'arrow') {
|
|
4705
|
+
const points = [{ x: shape.x1, y: shape.y1 }, { x: shape.x2, y: shape.y2 }];
|
|
4706
|
+
if (shape.type === 'arrow') {
|
|
4707
|
+
const geometry = annotArrowGeometry(points[0], points[1], shape.strokeWidth);
|
|
4708
|
+
if (geometry) points.push(...geometry.points);
|
|
4709
|
+
}
|
|
4710
|
+
const x = Math.min(...points.map((point) => point.x)) - pad;
|
|
4711
|
+
const y = Math.min(...points.map((point) => point.y)) - pad;
|
|
4712
|
+
return { x, y,
|
|
4713
|
+
w: Math.max(...points.map((point) => point.x)) - x + pad,
|
|
4714
|
+
h: Math.max(...points.map((point) => point.y)) - y + pad };
|
|
4715
|
+
}
|
|
4716
|
+
if (shape.type === 'brush') {
|
|
4717
|
+
const points = shape.points || [];
|
|
4718
|
+
if (!points.length) return null;
|
|
4719
|
+
let x1 = Infinity; let y1 = Infinity; let x2 = -Infinity; let y2 = -Infinity;
|
|
4720
|
+
for (const point of points) {
|
|
4721
|
+
x1 = Math.min(x1, point.x); y1 = Math.min(y1, point.y);
|
|
4722
|
+
x2 = Math.max(x2, point.x); y2 = Math.max(y2, point.y);
|
|
4723
|
+
}
|
|
4724
|
+
return { x: x1 - pad, y: y1 - pad, w: x2 - x1 + pad * 2, h: y2 - y1 + pad * 2 };
|
|
4725
|
+
}
|
|
4726
|
+
if (shape.type === 'text') {
|
|
4727
|
+
const svg = annotSvgRef.current;
|
|
4728
|
+
if (svg) {
|
|
4729
|
+
const id = String(shape.id || '').replace(/["\\]/g, '');
|
|
4730
|
+
const node = svg.querySelector('g[data-utb-annot-id="' + id + '"] text');
|
|
4731
|
+
if (node && typeof node.getBBox === 'function') {
|
|
4732
|
+
try {
|
|
4733
|
+
const box = node.getBBox();
|
|
4734
|
+
if (box.width > 0 && box.height > 0) return {
|
|
4735
|
+
// During React render the DOM may still contain the previous position.
|
|
4736
|
+
x: box.x + shape.x - Number(node.getAttribute('x')),
|
|
4737
|
+
y: box.y + shape.y - Number(node.getAttribute('y')),
|
|
4738
|
+
w: box.width, h: box.height,
|
|
4739
|
+
};
|
|
4740
|
+
} catch { /* fall through to the estimate */ }
|
|
4741
|
+
}
|
|
4742
|
+
}
|
|
4743
|
+
// Conservative estimate (CJK ≈ 1em, ASCII ≈ 0.5em) so a box selection still catches the text.
|
|
4744
|
+
const text = String(shape.text || '');
|
|
4745
|
+
let estimated = 0;
|
|
4746
|
+
for (const char of text) estimated += /[\u2e80-\u9fff\uf900-\ufaff\uff00-\uffef]/u.test(char) ? 20 : 10;
|
|
4747
|
+
return { x: shape.x, y: shape.y, w: Math.max(estimated, 20), h: 20 };
|
|
4748
|
+
}
|
|
4749
|
+
return null;
|
|
4750
|
+
}
|
|
4751
|
+
|
|
4752
|
+
function annotRectIntersects(a, b) {
|
|
4753
|
+
return a.x < b.x + b.w && a.x + a.w > b.x && a.y < b.y + b.h && a.y + a.h > b.y;
|
|
4754
|
+
}
|
|
4755
|
+
|
|
4756
|
+
/** Keep thin strokes easy to select at every zoom; eraser tolerance stays unchanged. */
|
|
4757
|
+
function annotTopmostAt(point, map) {
|
|
4758
|
+
const shapes = annotShapesRef.current;
|
|
4759
|
+
const scale = map ? Math.max(0.01, Math.min(map.sx, map.sy)) : 1;
|
|
4760
|
+
for (let index = shapes.length - 1; index >= 0; index -= 1) {
|
|
4761
|
+
const shape = shapes[index];
|
|
4762
|
+
const radius = Math.max(Number(shape.strokeWidth || 1) / 2, 6 / scale);
|
|
4763
|
+
if (annotHitsShape(shape, point, radius)) return shape;
|
|
4764
|
+
}
|
|
4765
|
+
return null;
|
|
4766
|
+
}
|
|
4767
|
+
|
|
4768
|
+
/** Union bounding box of the selected shapes (null when nothing is selected). */
|
|
4769
|
+
function annotSelectionBounds() {
|
|
4770
|
+
if (!annotSelectionRef.current.size) return null;
|
|
4771
|
+
let x1 = Infinity; let y1 = Infinity; let x2 = -Infinity; let y2 = -Infinity;
|
|
4772
|
+
for (const shape of annotShapesRef.current) {
|
|
4773
|
+
if (!annotSelectionRef.current.has(shape.id)) continue;
|
|
4774
|
+
const box = annotShapeBounds(shape);
|
|
4775
|
+
if (!box) continue;
|
|
4776
|
+
x1 = Math.min(x1, box.x); y1 = Math.min(y1, box.y);
|
|
4777
|
+
x2 = Math.max(x2, box.x + box.w); y2 = Math.max(y2, box.y + box.h);
|
|
4778
|
+
}
|
|
4779
|
+
if (!(x2 >= x1) || !(y2 >= y1)) return null;
|
|
4780
|
+
return { x: x1, y: y1, w: x2 - x1, h: y2 - y1 };
|
|
4781
|
+
}
|
|
4782
|
+
|
|
4783
|
+
/**
|
|
4784
|
+
* Inside the selection's union box. This is what makes selected shapes draggable from their
|
|
4785
|
+
* interior (a hollow rect's stroke-only hit test would miss) and lets a marquee-selected
|
|
4786
|
+
* group be dragged from the gap between its members.
|
|
4787
|
+
*/
|
|
4788
|
+
function annotSelectionContains(point) {
|
|
4789
|
+
const box = annotSelectionBounds();
|
|
4790
|
+
return Boolean(box && point.x >= box.x && point.x <= box.x + box.w
|
|
4791
|
+
&& point.y >= box.y && point.y <= box.y + box.h);
|
|
4792
|
+
}
|
|
4793
|
+
|
|
4794
|
+
/** Translate one shape by a canvas-space delta (all six geometries; no style change). */
|
|
4795
|
+
function annotTranslateShape(shape, dx, dy) {
|
|
4796
|
+
if (!dx && !dy) return shape;
|
|
4797
|
+
if (shape.type === 'circle') return Object.assign({}, shape, { cx: shape.cx + dx, cy: shape.cy + dy });
|
|
4798
|
+
if (shape.type === 'rect') return Object.assign({}, shape, { x: shape.x + dx, y: shape.y + dy });
|
|
4799
|
+
if (shape.type === 'line' || shape.type === 'arrow') {
|
|
4800
|
+
return Object.assign({}, shape, {
|
|
4801
|
+
x1: shape.x1 + dx, y1: shape.y1 + dy, x2: shape.x2 + dx, y2: shape.y2 + dy,
|
|
4802
|
+
});
|
|
4803
|
+
}
|
|
4804
|
+
if (shape.type === 'brush') {
|
|
4805
|
+
return Object.assign({}, shape, { points: (shape.points || []).map((point) => ({ x: point.x + dx, y: point.y + dy })) });
|
|
4806
|
+
}
|
|
4807
|
+
if (shape.type === 'text') return Object.assign({}, shape, { x: shape.x + dx, y: shape.y + dy });
|
|
4808
|
+
return shape;
|
|
4809
|
+
}
|
|
4810
|
+
|
|
4811
|
+
function annotSelectionOrigin() {
|
|
4812
|
+
const ids = annotSelectionRef.current;
|
|
4813
|
+
return annotShapesRef.current
|
|
4814
|
+
.filter((shape) => ids.has(shape.id))
|
|
4815
|
+
.map((shape) => ({ id: shape.id, shape }));
|
|
4816
|
+
}
|
|
4817
|
+
|
|
4818
|
+
function annotStartMove(map, event, point) {
|
|
4819
|
+
const origin = annotSelectionOrigin();
|
|
4820
|
+
if (!origin.length) return false;
|
|
4821
|
+
annotMarqueeRef.current = { active: false, pointerId: -1, map: null, start: null, end: null, node: null };
|
|
4822
|
+
annotMoveRef.current = {
|
|
4823
|
+
active: true, pointerId: event.pointerId, map, start: point, last: point, origin,
|
|
4824
|
+
originals: new Map(origin.map((item) => [item.id, item.shape])),
|
|
4825
|
+
bounds: new Map(origin.map((item) => [item.id, annotShapeBounds(item.shape)])),
|
|
4826
|
+
dragging: false, axis: null, dx: 0, dy: 0, changed: false,
|
|
4827
|
+
};
|
|
4828
|
+
annotMapSigRef.current = annotMapSignature(map);
|
|
4829
|
+
const svg = annotSvgRef.current;
|
|
4830
|
+
try { if (svg) svg.setPointerCapture(event.pointerId); } catch { /* ignore */ }
|
|
4831
|
+
setAnnotBusy(true);
|
|
4832
|
+
return true;
|
|
4833
|
+
}
|
|
4834
|
+
|
|
4835
|
+
function moveAnnotSelection(event) {
|
|
4836
|
+
const move = annotMoveRef.current;
|
|
4837
|
+
if (!move.active) return;
|
|
4838
|
+
const point = annotToCanvasRaw(move.map, event.clientX, event.clientY);
|
|
4839
|
+
move.last = point;
|
|
4840
|
+
let dx = point.x - move.start.x;
|
|
4841
|
+
let dy = point.y - move.start.y;
|
|
4842
|
+
const screen = Math.hypot(dx * move.map.sx, dy * move.map.sy);
|
|
4843
|
+
// Latch once: after a real drag, returning to the press point must restore the original.
|
|
4844
|
+
if (!move.dragging && screen < 4) return;
|
|
4845
|
+
move.dragging = true;
|
|
4846
|
+
if (event.shiftKey) {
|
|
4847
|
+
if (!move.axis) move.axis = Math.abs(dx * move.map.sx) >= Math.abs(dy * move.map.sy) ? 'x' : 'y';
|
|
4848
|
+
if (move.axis === 'x') dy = 0;
|
|
4849
|
+
else dx = 0;
|
|
4850
|
+
} else move.axis = null;
|
|
4851
|
+
if (dx === move.dx && dy === move.dy) return;
|
|
4852
|
+
move.dx = dx;
|
|
4853
|
+
move.dy = dy;
|
|
4854
|
+
move.changed = Boolean(dx || dy);
|
|
4855
|
+
annotShapesRef.current = annotShapesRef.current.map((shape) => {
|
|
4856
|
+
const base = move.originals.get(shape.id);
|
|
4857
|
+
return base ? annotTranslateShape(base, dx, dy) : shape;
|
|
4858
|
+
});
|
|
4859
|
+
annotSetHoverCursor('grabbing');
|
|
4860
|
+
annotSelectionScheduleRender();
|
|
4861
|
+
}
|
|
4862
|
+
|
|
4863
|
+
function finishAnnotMove() {
|
|
4864
|
+
const move = annotMoveRef.current;
|
|
4865
|
+
if (!move.active) return;
|
|
4866
|
+
const svg = annotSvgRef.current;
|
|
4867
|
+
try { if (svg) svg.releasePointerCapture(move.pointerId); } catch { /* ignore */ }
|
|
4868
|
+
const changed = move.changed;
|
|
4869
|
+
annotMoveRef.current = { active: false, pointerId: -1, map: null, start: null, last: null, origin: null, changed: false };
|
|
4870
|
+
if (move.acceptedShapes) {
|
|
4871
|
+
annotApplyImported(move.acceptedShapes);
|
|
4872
|
+
if (changed) annotShapesRef.current = annotShapesRef.current.map((shape) => move.originals.has(shape.id)
|
|
4873
|
+
? annotTranslateShape(shape, move.dx, move.dy) : shape);
|
|
4874
|
+
}
|
|
4875
|
+
setAnnotBusy(false);
|
|
4876
|
+
if (changed) {
|
|
4877
|
+
annotContentSeqRef.current += 1;
|
|
4878
|
+
annotDirtyRef.current = true;
|
|
4879
|
+
annotPublishSchedule();
|
|
4880
|
+
}
|
|
4881
|
+
if (!move.dragging && move.toggleOnClick) annotSetSelection([move.toggleOnClick], true);
|
|
4882
|
+
annotSetHoverCursor('move');
|
|
4883
|
+
annotSelectionRefresh();
|
|
4884
|
+
annotResumeAfterTransient();
|
|
4885
|
+
}
|
|
4886
|
+
|
|
4887
|
+
/** Roll back a move gesture to the shapes captured at pointerdown; no publish happens. */
|
|
4888
|
+
function cancelAnnotMove() {
|
|
4889
|
+
const move = annotMoveRef.current;
|
|
4890
|
+
if (!move.active) return;
|
|
4891
|
+
const origin = new Map((move.origin || []).map((item) => [item.id, item.shape]));
|
|
4892
|
+
annotShapesRef.current = annotShapesRef.current.map((shape) => origin.get(shape.id) || shape);
|
|
4893
|
+
const svg = annotSvgRef.current;
|
|
4894
|
+
try { if (svg) svg.releasePointerCapture(move.pointerId); } catch { /* ignore */ }
|
|
4895
|
+
annotMoveRef.current = { active: false, pointerId: -1, map: null, start: null, last: null, origin: null, changed: false };
|
|
4896
|
+
if (move.acceptedShapes) annotApplyImported(move.acceptedShapes);
|
|
4897
|
+
if (annotSelectionRafRef.current) {
|
|
4898
|
+
window.cancelAnimationFrame(annotSelectionRafRef.current);
|
|
4899
|
+
annotSelectionRafRef.current = 0;
|
|
4900
|
+
}
|
|
4901
|
+
setAnnotBusy(false);
|
|
4902
|
+
annotSelectionRefresh();
|
|
4903
|
+
annotResumeAfterTransient();
|
|
4904
|
+
}
|
|
4905
|
+
|
|
4906
|
+
function annotMarqueeRect(marquee) {
|
|
4907
|
+
const start = marquee.start || { x: 0, y: 0 };
|
|
4908
|
+
const end = marquee.end || start;
|
|
4909
|
+
return {
|
|
4910
|
+
x: Math.min(start.x, end.x), y: Math.min(start.y, end.y),
|
|
4911
|
+
w: Math.abs(end.x - start.x), h: Math.abs(end.y - start.y),
|
|
4912
|
+
};
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4915
|
+
/** The marquee preview lives in the imperative draft group (no React render per move). */
|
|
4916
|
+
function annotMarqueeNode() {
|
|
4917
|
+
const marquee = annotMarqueeRef.current;
|
|
4918
|
+
if (marquee.node && marquee.node.isConnected) return marquee.node;
|
|
4919
|
+
const draft = annotDraftRef.current;
|
|
4920
|
+
if (!draft) return null;
|
|
4921
|
+
const node = annotDraftNode('rect', { x: 0, y: 0, width: 0, height: 0, fill: 'none' });
|
|
4922
|
+
node.setAttribute('class', 'utb-annot-marquee-box');
|
|
4923
|
+
node.setAttribute('data-utb-annot-marquee', 'true');
|
|
4924
|
+
draft.appendChild(node);
|
|
4925
|
+
marquee.node = node;
|
|
4926
|
+
return node;
|
|
4927
|
+
}
|
|
4928
|
+
|
|
4929
|
+
function renderAnnotMarquee() {
|
|
4930
|
+
const marquee = annotMarqueeRef.current;
|
|
4931
|
+
if (!marquee.active) return;
|
|
4932
|
+
const node = annotMarqueeNode();
|
|
4933
|
+
if (!node) return;
|
|
4934
|
+
const rect = annotMarqueeRect(marquee);
|
|
4935
|
+
node.setAttribute('x', String(rect.x));
|
|
4936
|
+
node.setAttribute('y', String(rect.y));
|
|
4937
|
+
node.setAttribute('width', String(rect.w));
|
|
4938
|
+
node.setAttribute('height', String(rect.h));
|
|
4939
|
+
}
|
|
4940
|
+
|
|
4941
|
+
function startAnnotMarquee(map, event, point) {
|
|
4942
|
+
cancelAnnotMove();
|
|
4943
|
+
resetAnnotMarquee();
|
|
4944
|
+
cancelAnnotDraft();
|
|
4945
|
+
annotMarqueeRef.current = {
|
|
4946
|
+
active: true, pointerId: event.pointerId, map, start: point, end: point,
|
|
4947
|
+
mode: annotSelectionMode(event), base: new Set(annotSelectionRef.current), dragging: false,
|
|
4948
|
+
// Cache bounds once; text getBBox and stroke geometry need not run on every pointermove.
|
|
4949
|
+
boxes: annotShapesRef.current.map((shape) => ({ id: shape.id, box: annotShapeBounds(shape) })),
|
|
4950
|
+
node: null,
|
|
4951
|
+
};
|
|
4952
|
+
annotMapSigRef.current = annotMapSignature(map);
|
|
4953
|
+
const svg = annotSvgRef.current;
|
|
4954
|
+
try { if (svg) svg.setPointerCapture(event.pointerId); } catch { /* ignore */ }
|
|
4955
|
+
setAnnotBusy(true);
|
|
4956
|
+
}
|
|
4957
|
+
|
|
4958
|
+
function moveAnnotMarquee(event) {
|
|
4959
|
+
const marquee = annotMarqueeRef.current;
|
|
4960
|
+
if (!marquee.active) return;
|
|
4961
|
+
marquee.end = annotToCanvasRaw(marquee.map, event.clientX, event.clientY);
|
|
4962
|
+
const rect = annotMarqueeRect(marquee);
|
|
4963
|
+
if (!marquee.dragging && Math.hypot(rect.w * marquee.map.sx, rect.h * marquee.map.sy) < 4) return;
|
|
4964
|
+
marquee.dragging = true;
|
|
4965
|
+
marquee.mode = annotSelectionMode(event);
|
|
4966
|
+
renderAnnotMarquee();
|
|
4967
|
+
const ids = marquee.boxes.filter((item) => item.box && annotRectIntersects(item.box, rect)).map((item) => item.id);
|
|
4968
|
+
annotApplySelection(ids, marquee.mode, marquee.base, true);
|
|
4969
|
+
if (marquee.node) marquee.node.setAttribute('data-utb-annot-marquee-mode', marquee.mode);
|
|
4970
|
+
annotSetHoverCursor('crosshair');
|
|
4971
|
+
}
|
|
4972
|
+
|
|
4973
|
+
function resetAnnotMarquee() {
|
|
4974
|
+
const marquee = annotMarqueeRef.current;
|
|
4975
|
+
const svg = annotSvgRef.current;
|
|
4976
|
+
if (marquee.active && svg && marquee.pointerId >= 0) {
|
|
4977
|
+
try { svg.releasePointerCapture(marquee.pointerId); } catch { /* ignore */ }
|
|
4978
|
+
}
|
|
4979
|
+
if (marquee.node && marquee.node.parentNode) marquee.node.parentNode.removeChild(marquee.node);
|
|
4980
|
+
annotMarqueeRef.current = { active: false, pointerId: -1, map: null, start: null, end: null, node: null };
|
|
4981
|
+
}
|
|
4982
|
+
|
|
4983
|
+
function finishAnnotMarquee() {
|
|
4984
|
+
const marquee = annotMarqueeRef.current;
|
|
4985
|
+
if (!marquee.active) return;
|
|
4986
|
+
// Preview is already the final set. A modified empty click preserves the base selection.
|
|
4987
|
+
if (!marquee.dragging) annotApplySelection([], marquee.mode === 'replace' ? 'replace' : 'add', marquee.base, false);
|
|
4988
|
+
resetAnnotMarquee();
|
|
4989
|
+
clearAnnotDraftNode();
|
|
4990
|
+
setAnnotBusy(false);
|
|
4991
|
+
annotSelectionRefresh();
|
|
4992
|
+
annotResumeAfterTransient();
|
|
4993
|
+
}
|
|
4994
|
+
|
|
4995
|
+
function cancelAnnotMarquee() {
|
|
4996
|
+
const marquee = annotMarqueeRef.current;
|
|
4997
|
+
if (!marquee.active) return;
|
|
4998
|
+
annotSelectionRef.current = new Set(marquee.base);
|
|
4999
|
+
resetAnnotMarquee();
|
|
5000
|
+
clearAnnotDraftNode();
|
|
5001
|
+
setAnnotBusy(false);
|
|
5002
|
+
annotSelectionRefresh();
|
|
5003
|
+
annotResumeAfterTransient();
|
|
5004
|
+
}
|
|
5005
|
+
|
|
5006
|
+
/** Delete every selected shape (one document change, one coalesced publish). */
|
|
5007
|
+
function annotDeleteSelection() {
|
|
5008
|
+
if (!annotRef.current.on) return false;
|
|
5009
|
+
cancelAnnotMove();
|
|
5010
|
+
cancelAnnotMarquee();
|
|
5011
|
+
if (!annotSelectionRef.current.size) return false;
|
|
5012
|
+
const ids = annotSelectionRef.current;
|
|
5013
|
+
const before = annotShapesRef.current.length;
|
|
5014
|
+
annotShapesRef.current = annotShapesRef.current.filter((shape) => !ids.has(shape.id));
|
|
5015
|
+
annotSelectionRef.current = new Set();
|
|
5016
|
+
const removed = before - annotShapesRef.current.length;
|
|
5017
|
+
if (removed > 0) {
|
|
5018
|
+
annotContentSeqRef.current += 1;
|
|
5019
|
+
annotDirtyRef.current = true;
|
|
5020
|
+
annotPublishSchedule();
|
|
5021
|
+
}
|
|
5022
|
+
annotSelectionRefresh();
|
|
5023
|
+
return removed > 0;
|
|
5024
|
+
}
|
|
5025
|
+
|
|
4551
5026
|
/** One sample point deletes the topmost hit only (no distance re-ranking, no drill-through). */
|
|
4552
5027
|
function annotEraseAt(point) {
|
|
4553
5028
|
const shapes = annotShapesRef.current;
|
|
@@ -4555,6 +5030,7 @@ window.__ModuleLoader__.load({
|
|
|
4555
5030
|
if (annotHitsShape(shapes[index], point)) {
|
|
4556
5031
|
annotShapesRef.current = shapes.slice(0, index).concat(shapes.slice(index + 1));
|
|
4557
5032
|
annotEraseRef.current.changed += 1;
|
|
5033
|
+
annotPruneSelection();
|
|
4558
5034
|
annotContentSeqRef.current += 1;
|
|
4559
5035
|
annotDirtyRef.current = true;
|
|
4560
5036
|
setAnnotVersion((value) => value + 1);
|
|
@@ -4710,6 +5186,9 @@ window.__ModuleLoader__.load({
|
|
|
4710
5186
|
if (annotEditRef.current.active) cancelAnnotText();
|
|
4711
5187
|
cancelAnnotDraft();
|
|
4712
5188
|
resetAnnotErase();
|
|
5189
|
+
resetAnnotMarquee();
|
|
5190
|
+
annotMoveRef.current = { active: false, pointerId: -1, map: null, start: null, last: null, origin: null, changed: false };
|
|
5191
|
+
annotSelectionRef.current = new Set();
|
|
4713
5192
|
annotShapesRef.current = [];
|
|
4714
5193
|
annotContentSeqRef.current += 1;
|
|
4715
5194
|
annotDirtyRef.current = true;
|
|
@@ -4975,6 +5454,38 @@ window.__ModuleLoader__.load({
|
|
|
4975
5454
|
startAnnotErase(map, event, point);
|
|
4976
5455
|
return;
|
|
4977
5456
|
}
|
|
5457
|
+
if (annotRef.current.tool === 'select') {
|
|
5458
|
+
cancelAnnotDraft();
|
|
5459
|
+
const hit = annotTopmostAt(point, map);
|
|
5460
|
+
const mode = annotSelectionMode(event);
|
|
5461
|
+
if (!hit) {
|
|
5462
|
+
if (mode === 'replace' && annotSelectionContains(point)) annotStartMove(map, event, point);
|
|
5463
|
+
else startAnnotMarquee(map, event, point); // empty-space drag works with either tool
|
|
5464
|
+
return;
|
|
5465
|
+
}
|
|
5466
|
+
if (mode === 'subtract' || mode === 'intersect') {
|
|
5467
|
+
annotApplySelection([hit.id], mode, annotSelectionRef.current, false);
|
|
5468
|
+
return;
|
|
5469
|
+
}
|
|
5470
|
+
if (mode !== 'replace' && annotSelectionRef.current.has(hit.id)) {
|
|
5471
|
+
// Shift-click toggles on release, but Shift-drag keeps the group and locks its axis.
|
|
5472
|
+
annotStartMove(map, event, point);
|
|
5473
|
+
annotMoveRef.current.toggleOnClick = hit.id;
|
|
5474
|
+
return;
|
|
5475
|
+
}
|
|
5476
|
+
if (!annotSelectionRef.current.has(hit.id)) annotApplySelection([hit.id], mode, annotSelectionRef.current, false);
|
|
5477
|
+
annotStartMove(map, event, point);
|
|
5478
|
+
return;
|
|
5479
|
+
}
|
|
5480
|
+
if (annotRef.current.tool === 'marquee') {
|
|
5481
|
+
if (annotSelectionMode(event) === 'replace' && annotSelectionContains(point)) {
|
|
5482
|
+
cancelAnnotDraft();
|
|
5483
|
+
annotStartMove(map, event, point);
|
|
5484
|
+
return;
|
|
5485
|
+
}
|
|
5486
|
+
startAnnotMarquee(map, event, point);
|
|
5487
|
+
return;
|
|
5488
|
+
}
|
|
4978
5489
|
cancelAnnotDraft();
|
|
4979
5490
|
setAnnotBusy(true);
|
|
4980
5491
|
const style = annotSnapshot();
|
|
@@ -4992,6 +5503,29 @@ window.__ModuleLoader__.load({
|
|
|
4992
5503
|
try { if (svg) svg.setPointerCapture(event.pointerId); } catch { /* ignore */ }
|
|
4993
5504
|
}
|
|
4994
5505
|
|
|
5506
|
+
function annotSetHoverCursor(mode) {
|
|
5507
|
+
const next = ['move', 'grabbing', 'crosshair'].includes(mode) ? mode : 'default';
|
|
5508
|
+
annotHoverRef.current = next;
|
|
5509
|
+
const svg = annotSvgRef.current;
|
|
5510
|
+
if (svg) svg.style.cursor = next;
|
|
5511
|
+
}
|
|
5512
|
+
|
|
5513
|
+
/** Select/marquee hover: 'move' over a shape or inside the selection box, else the tool base. */
|
|
5514
|
+
function annotUpdateHoverCursor(event) {
|
|
5515
|
+
const base = annotRef.current.tool === 'marquee' ? 'crosshair' : 'default';
|
|
5516
|
+
const map = annotMap();
|
|
5517
|
+
if (!map || !annotInside(map, event.clientX, event.clientY)) {
|
|
5518
|
+
annotSetHoverCursor(base);
|
|
5519
|
+
return;
|
|
5520
|
+
}
|
|
5521
|
+
const point = annotToCanvas(map, event.clientX, event.clientY);
|
|
5522
|
+
const modified = annotSelectionMode(event) !== 'replace';
|
|
5523
|
+
const movable = annotRef.current.tool === 'select'
|
|
5524
|
+
? !event.altKey && (annotTopmostAt(point, map) || (!modified && annotSelectionContains(point)))
|
|
5525
|
+
: !modified && annotSelectionContains(point);
|
|
5526
|
+
annotSetHoverCursor(movable ? 'move' : base);
|
|
5527
|
+
}
|
|
5528
|
+
|
|
4995
5529
|
function onAnnotPointerMove(event) {
|
|
4996
5530
|
if (annotRef.current.on && annotEraseRef.current.active
|
|
4997
5531
|
&& annotEraseRef.current.pointerId === event.pointerId) {
|
|
@@ -4999,6 +5533,23 @@ window.__ModuleLoader__.load({
|
|
|
4999
5533
|
moveAnnotErase(event);
|
|
5000
5534
|
return;
|
|
5001
5535
|
}
|
|
5536
|
+
if (annotMoveRef.current.active && annotMoveRef.current.pointerId === event.pointerId) {
|
|
5537
|
+
if (!annotRef.current.on) return;
|
|
5538
|
+
event.preventDefault();
|
|
5539
|
+
moveAnnotSelection(event);
|
|
5540
|
+
return;
|
|
5541
|
+
}
|
|
5542
|
+
if (annotMarqueeRef.current.active && annotMarqueeRef.current.pointerId === event.pointerId) {
|
|
5543
|
+
if (!annotRef.current.on) return;
|
|
5544
|
+
event.preventDefault();
|
|
5545
|
+
moveAnnotMarquee(event);
|
|
5546
|
+
return;
|
|
5547
|
+
}
|
|
5548
|
+
if (annotRef.current.on && (annotRef.current.tool === 'select' || annotRef.current.tool === 'marquee')
|
|
5549
|
+
&& !annotGestureRef.current.active) {
|
|
5550
|
+
annotUpdateHoverCursor(event);
|
|
5551
|
+
return;
|
|
5552
|
+
}
|
|
5002
5553
|
const gesture = annotGestureRef.current;
|
|
5003
5554
|
if (!annotRef.current.on || !gesture.active || gesture.pointerId !== event.pointerId) return;
|
|
5004
5555
|
event.preventDefault();
|
|
@@ -5026,6 +5577,18 @@ window.__ModuleLoader__.load({
|
|
|
5026
5577
|
finishAnnotErase();
|
|
5027
5578
|
return;
|
|
5028
5579
|
}
|
|
5580
|
+
if (annotMoveRef.current.active && annotMoveRef.current.pointerId === event.pointerId) {
|
|
5581
|
+
event.preventDefault();
|
|
5582
|
+
moveAnnotSelection(event);
|
|
5583
|
+
finishAnnotMove();
|
|
5584
|
+
return;
|
|
5585
|
+
}
|
|
5586
|
+
if (annotMarqueeRef.current.active && annotMarqueeRef.current.pointerId === event.pointerId) {
|
|
5587
|
+
event.preventDefault();
|
|
5588
|
+
moveAnnotMarquee(event);
|
|
5589
|
+
finishAnnotMarquee();
|
|
5590
|
+
return;
|
|
5591
|
+
}
|
|
5029
5592
|
const gesture = annotGestureRef.current;
|
|
5030
5593
|
if (!annotRef.current.on || !gesture.active || gesture.pointerId !== event.pointerId) return;
|
|
5031
5594
|
event.preventDefault();
|
|
@@ -5050,6 +5613,16 @@ window.__ModuleLoader__.load({
|
|
|
5050
5613
|
finishAnnotErase();
|
|
5051
5614
|
return;
|
|
5052
5615
|
}
|
|
5616
|
+
if (annotMoveRef.current.active
|
|
5617
|
+
&& (event === undefined || event.pointerId === undefined || annotMoveRef.current.pointerId === event.pointerId)) {
|
|
5618
|
+
cancelAnnotMove();
|
|
5619
|
+
return;
|
|
5620
|
+
}
|
|
5621
|
+
if (annotMarqueeRef.current.active
|
|
5622
|
+
&& (event === undefined || event.pointerId === undefined || annotMarqueeRef.current.pointerId === event.pointerId)) {
|
|
5623
|
+
cancelAnnotMarquee();
|
|
5624
|
+
return;
|
|
5625
|
+
}
|
|
5053
5626
|
const gesture = annotGestureRef.current;
|
|
5054
5627
|
if (!gesture.active) return;
|
|
5055
5628
|
if (event && event.pointerId !== undefined && gesture.pointerId !== event.pointerId) return;
|
|
@@ -5114,6 +5687,8 @@ window.__ModuleLoader__.load({
|
|
|
5114
5687
|
/** Cancel the uncommitted draft/edit only; committed shapes stay (mode exit keeps them). */
|
|
5115
5688
|
function annotCancelTransient() {
|
|
5116
5689
|
cancelAnnotDraft();
|
|
5690
|
+
cancelAnnotMove();
|
|
5691
|
+
cancelAnnotMarquee();
|
|
5117
5692
|
resetAnnotErase();
|
|
5118
5693
|
setAnnotBusy(false);
|
|
5119
5694
|
const edit = annotEditRef.current;
|
|
@@ -5162,6 +5737,7 @@ window.__ModuleLoader__.load({
|
|
|
5162
5737
|
if (!annotRef.current.on) return;
|
|
5163
5738
|
// Closing the panel exits the mode but keeps every committed annotation visible (contract §5).
|
|
5164
5739
|
annotCancelTransient();
|
|
5740
|
+
annotSelectionRef.current = new Set();
|
|
5165
5741
|
setAnnotText(null);
|
|
5166
5742
|
resetAnnotErase();
|
|
5167
5743
|
setAnnotBusy(false);
|
|
@@ -5458,7 +6034,12 @@ window.__ModuleLoader__.load({
|
|
|
5458
6034
|
function chooseAnnotTool(tool) {
|
|
5459
6035
|
if (!annotRef.current.on || tool === annotRef.current.tool) return;
|
|
5460
6036
|
cancelAnnotDraft();
|
|
6037
|
+
cancelAnnotMove();
|
|
6038
|
+
cancelAnnotMarquee();
|
|
6039
|
+
annotSetHoverCursor(tool === 'marquee' ? 'crosshair' : 'default');
|
|
5461
6040
|
if (annotEditRef.current.active) commitAnnotText();
|
|
6041
|
+
// 切回绘制/橡皮擦/文字时清空选择;选择 ↔ 框选之间保留,方便先点选再框选追加。
|
|
6042
|
+
if (!annotIsSelectionTool(tool)) annotClearSelection();
|
|
5462
6043
|
annotRef.current.tool = tool;
|
|
5463
6044
|
const style = annotStyleFor(tool, annotRef.current.mode || 'stroke');
|
|
5464
6045
|
annotRef.current.fill = style.fill;
|
|
@@ -5506,9 +6087,10 @@ window.__ModuleLoader__.load({
|
|
|
5506
6087
|
}
|
|
5507
6088
|
|
|
5508
6089
|
/** Committed shape → React SVG node. Geometry data lives on the root g; native geometry on
|
|
5509
|
-
* the child element, so data, SVG and screen position can all be checked independently.
|
|
5510
|
-
|
|
5511
|
-
|
|
6090
|
+
* the child element, so data, SVG and screen position can all be checked independently.
|
|
6091
|
+
* `extra` is used by the selection halo (same geometry, accent styling, no committed state). */
|
|
6092
|
+
function renderAnnotShape(shape, extra) {
|
|
6093
|
+
const common = Object.assign({
|
|
5512
6094
|
key: shape.id,
|
|
5513
6095
|
'data-utb-annot-id': shape.id,
|
|
5514
6096
|
'data-utb-annot-type': shape.type,
|
|
@@ -5518,7 +6100,7 @@ window.__ModuleLoader__.load({
|
|
|
5518
6100
|
'data-utb-annot-fill': shape.fill ? 'true' : 'false',
|
|
5519
6101
|
'data-utb-annot-stroke': shape.stroke ? 'true' : 'false',
|
|
5520
6102
|
style: { pointerEvents: 'none' },
|
|
5521
|
-
};
|
|
6103
|
+
}, extra || {});
|
|
5522
6104
|
if (shape.type === 'circle') {
|
|
5523
6105
|
return h('g', Object.assign({}, common, {
|
|
5524
6106
|
'data-utb-annot-cx': String(shape.cx),
|
|
@@ -7378,18 +7960,91 @@ window.__ModuleLoader__.load({
|
|
|
7378
7960
|
return () => { document.removeEventListener('mousedown', close); document.removeEventListener('keydown', escape); };
|
|
7379
7961
|
}, [menu]);
|
|
7380
7962
|
|
|
7381
|
-
// Annotation mode:
|
|
7382
|
-
//
|
|
7963
|
+
// Annotation mode: Delete removes the selection; Esc order is
|
|
7964
|
+
// composition → move/marquee cancel → selection clear → editor/draft cancel → exit.
|
|
7965
|
+
// Both listeners live only while the mode is on.
|
|
7383
7966
|
React.useEffect(() => {
|
|
7384
7967
|
if (!annotOn) return undefined;
|
|
7968
|
+
const updateModifiers = (event) => {
|
|
7969
|
+
if (!['Shift', 'Alt', 'Control', 'Meta'].includes(event.key)) return;
|
|
7970
|
+
const move = annotMoveRef.current;
|
|
7971
|
+
const marquee = annotMarqueeRef.current;
|
|
7972
|
+
const gesture = move.active ? move : marquee.active ? marquee : null;
|
|
7973
|
+
if (!gesture) return;
|
|
7974
|
+
const point = move.active ? move.last : marquee.end;
|
|
7975
|
+
const map = gesture.map;
|
|
7976
|
+
const input = {
|
|
7977
|
+
clientX: map.painted.left + (point.x - map.cx) * map.sx,
|
|
7978
|
+
clientY: map.painted.top + (point.y - map.cy) * map.sy,
|
|
7979
|
+
shiftKey: event.shiftKey, altKey: event.altKey, ctrlKey: event.ctrlKey, metaKey: event.metaKey,
|
|
7980
|
+
};
|
|
7981
|
+
if (move.active) moveAnnotSelection(input);
|
|
7982
|
+
else moveAnnotMarquee(input);
|
|
7983
|
+
};
|
|
7385
7984
|
const onKeyDown = (event) => {
|
|
7386
|
-
if (event.key !== 'Escape') return;
|
|
7387
7985
|
if (annotComposingRef.current || event.isComposing) return;
|
|
7986
|
+
updateModifiers(event);
|
|
7987
|
+
const editable = annotEditRef.current.active || pickEditableTarget(event.target)
|
|
7988
|
+
|| pickEditableTarget(document.activeElement);
|
|
7989
|
+
const svg = annotSvgRef.current;
|
|
7990
|
+
const canvasFocused = svg && (document.activeElement === svg || svg.contains(document.activeElement));
|
|
7991
|
+
const idle = !annotMoveRef.current.active && !annotMarqueeRef.current.active && !annotGestureRef.current.active;
|
|
7992
|
+
if (!editable && canvasFocused && idle && !event.altKey) {
|
|
7993
|
+
const key = event.key.toLowerCase();
|
|
7994
|
+
const command = event.ctrlKey || event.metaKey;
|
|
7995
|
+
if ((!command && (key === 'v' || key === 'm'))
|
|
7996
|
+
|| (command && annotIsSelectionTool(annotRef.current.tool) && (key === 'a' || key === 'd'))) {
|
|
7997
|
+
event.preventDefault();
|
|
7998
|
+
event.stopPropagation();
|
|
7999
|
+
if (command) {
|
|
8000
|
+
if (annotIsSelectionTool(annotRef.current.tool)) {
|
|
8001
|
+
if (key === 'a') annotSetSelection(annotShapesRef.current.map((shape) => shape.id), false);
|
|
8002
|
+
else annotClearSelection();
|
|
8003
|
+
}
|
|
8004
|
+
} else chooseAnnotTool(key === 'v' ? 'select' : 'marquee');
|
|
8005
|
+
return;
|
|
8006
|
+
}
|
|
8007
|
+
const directions = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, -1], ArrowDown: [0, 1] };
|
|
8008
|
+
const direction = directions[event.key];
|
|
8009
|
+
if (!command && direction && annotIsSelectionTool(annotRef.current.tool) && annotSelectionRef.current.size) {
|
|
8010
|
+
event.preventDefault();
|
|
8011
|
+
event.stopPropagation();
|
|
8012
|
+
const step = event.shiftKey ? 10 : 1;
|
|
8013
|
+
annotShapesRef.current = annotShapesRef.current.map((shape) => annotSelectionRef.current.has(shape.id)
|
|
8014
|
+
? annotTranslateShape(shape, direction[0] * step, direction[1] * step) : shape);
|
|
8015
|
+
annotContentSeqRef.current += 1;
|
|
8016
|
+
annotDirtyRef.current = true;
|
|
8017
|
+
annotPublishSchedule();
|
|
8018
|
+
annotSelectionScheduleRender();
|
|
8019
|
+
return;
|
|
8020
|
+
}
|
|
8021
|
+
}
|
|
8022
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
8023
|
+
// 输入框/可编辑区里的 Backspace 属于编辑,不劫持;没有选中时不消费按键。
|
|
8024
|
+
if (annotEditRef.current.active || pickEditableTarget(event.target)
|
|
8025
|
+
|| pickEditableTarget(document.activeElement)) return;
|
|
8026
|
+
if (!annotSelectionRef.current.size) return;
|
|
8027
|
+
event.preventDefault();
|
|
8028
|
+
event.stopPropagation();
|
|
8029
|
+
annotDeleteSelection();
|
|
8030
|
+
return;
|
|
8031
|
+
}
|
|
8032
|
+
if (event.key !== 'Escape') return;
|
|
7388
8033
|
if (annotEditRef.current.active) {
|
|
7389
8034
|
event.preventDefault();
|
|
7390
8035
|
cancelAnnotText();
|
|
7391
8036
|
return;
|
|
7392
8037
|
}
|
|
8038
|
+
if (annotMoveRef.current.active) {
|
|
8039
|
+
event.preventDefault();
|
|
8040
|
+
cancelAnnotMove();
|
|
8041
|
+
return;
|
|
8042
|
+
}
|
|
8043
|
+
if (annotMarqueeRef.current.active) {
|
|
8044
|
+
event.preventDefault();
|
|
8045
|
+
cancelAnnotMarquee();
|
|
8046
|
+
return;
|
|
8047
|
+
}
|
|
7393
8048
|
if (annotEraseRef.current.active) {
|
|
7394
8049
|
// 擦掉的对象不回滚;这里只结束手势,不退出模式(§11.4)。
|
|
7395
8050
|
event.preventDefault();
|
|
@@ -7401,13 +8056,23 @@ window.__ModuleLoader__.load({
|
|
|
7401
8056
|
cancelAnnotDraft();
|
|
7402
8057
|
return;
|
|
7403
8058
|
}
|
|
8059
|
+
if (annotSelectionRef.current.size) {
|
|
8060
|
+
// 选择不是持久状态:第一下 Esc 只取消选择,再按才退出模式。
|
|
8061
|
+
event.preventDefault();
|
|
8062
|
+
annotClearSelection();
|
|
8063
|
+
return;
|
|
8064
|
+
}
|
|
7404
8065
|
event.preventDefault();
|
|
7405
8066
|
exitAnnotMode();
|
|
7406
8067
|
const button = annotButtonRef.current;
|
|
7407
8068
|
if (button) { try { button.focus({ preventScroll: true }); } catch { /* ignore */ } }
|
|
7408
8069
|
};
|
|
7409
8070
|
document.addEventListener('keydown', onKeyDown, true);
|
|
7410
|
-
|
|
8071
|
+
document.addEventListener('keyup', updateModifiers, true);
|
|
8072
|
+
return () => {
|
|
8073
|
+
document.removeEventListener('keydown', onKeyDown, true);
|
|
8074
|
+
document.removeEventListener('keyup', updateModifiers, true);
|
|
8075
|
+
};
|
|
7411
8076
|
}, [annotOn]);
|
|
7412
8077
|
|
|
7413
8078
|
// A stage scroll changes the painted rect without a React render: an active gesture/edit must
|
|
@@ -7417,8 +8082,11 @@ window.__ModuleLoader__.load({
|
|
|
7417
8082
|
if (!node || typeof node.addEventListener !== 'function') return undefined;
|
|
7418
8083
|
const onScroll = () => {
|
|
7419
8084
|
if (!annotRef.current.on) return;
|
|
7420
|
-
if (annotGestureRef.current.active || annotEditRef.current.active
|
|
8085
|
+
if (annotGestureRef.current.active || annotEditRef.current.active
|
|
8086
|
+
|| annotMoveRef.current.active || annotMarqueeRef.current.active) {
|
|
7421
8087
|
cancelAnnotDraft();
|
|
8088
|
+
cancelAnnotMove();
|
|
8089
|
+
cancelAnnotMarquee();
|
|
7422
8090
|
cancelAnnotText();
|
|
7423
8091
|
}
|
|
7424
8092
|
};
|
|
@@ -7771,9 +8439,12 @@ window.__ModuleLoader__.load({
|
|
|
7771
8439
|
if (!annotOn || !previous || annotMapSignatureNow === previous) return;
|
|
7772
8440
|
// An external view/geometry change cancels the uncommitted draft/edit; committed shapes stay
|
|
7773
8441
|
// and are re-mapped by the new viewBox (docs/viewport-control.md).
|
|
7774
|
-
if (annotGestureRef.current.active || annotEditRef.current.active || annotEraseRef.current.active
|
|
8442
|
+
if (annotGestureRef.current.active || annotEditRef.current.active || annotEraseRef.current.active
|
|
8443
|
+
|| annotMoveRef.current.active || annotMarqueeRef.current.active) {
|
|
7775
8444
|
cancelAnnotDraft();
|
|
7776
8445
|
cancelAnnotText();
|
|
8446
|
+
cancelAnnotMove();
|
|
8447
|
+
cancelAnnotMarquee();
|
|
7777
8448
|
resetAnnotErase();
|
|
7778
8449
|
setAnnotBusy(false);
|
|
7779
8450
|
}
|
|
@@ -7800,6 +8471,7 @@ window.__ModuleLoader__.load({
|
|
|
7800
8471
|
resetAnnotErase();
|
|
7801
8472
|
setAnnotBusy(false);
|
|
7802
8473
|
annotShapesRef.current = [];
|
|
8474
|
+
annotSelectionRef.current = new Set();
|
|
7803
8475
|
annotCandidateRef.current = null;
|
|
7804
8476
|
setAnnotVersion((value) => value + 1);
|
|
7805
8477
|
annotDirtyRef.current = false;
|
|
@@ -8341,7 +9013,7 @@ h('div', { className: 'utb-bar' },
|
|
|
8341
9013
|
className: 'utb-annot-color',
|
|
8342
9014
|
value: annotColor,
|
|
8343
9015
|
'aria-label': '颜色',
|
|
8344
|
-
disabled: annotTool === 'eraser',
|
|
9016
|
+
disabled: annotTool === 'eraser' || annotIsSelectionTool(annotTool),
|
|
8345
9017
|
onChange: (e) => changeAnnotStyle({ color: String(e.target.value || '').toLowerCase() }),
|
|
8346
9018
|
})),
|
|
8347
9019
|
h('label', { className: 'utb-annot-check', title: '粗细(1–20 画布 px)' },
|
|
@@ -8355,7 +9027,7 @@ h('div', { className: 'utb-bar' },
|
|
|
8355
9027
|
value: String(annotWidth),
|
|
8356
9028
|
'aria-label': '粗细',
|
|
8357
9029
|
'data-utb-annot-field': 'width',
|
|
8358
|
-
disabled: annotTool === 'text' || annotTool === 'eraser',
|
|
9030
|
+
disabled: annotTool === 'text' || annotTool === 'eraser' || annotIsSelectionTool(annotTool),
|
|
8359
9031
|
onChange: (e) => {
|
|
8360
9032
|
const numeric = Number(e.target.value);
|
|
8361
9033
|
if (!Number.isFinite(numeric) || String(e.target.value).trim() === '') return;
|
|
@@ -8381,6 +9053,16 @@ h('div', { className: 'utb-bar' },
|
|
|
8381
9053
|
'aria-live': 'polite',
|
|
8382
9054
|
'data-utb-annot-shot-notice': 'true',
|
|
8383
9055
|
}, annotShotNotice),
|
|
9056
|
+
annotSelectionCount() > 0 && h('button', {
|
|
9057
|
+
type: 'button',
|
|
9058
|
+
className: 'utb-btn utb-annot-delete',
|
|
9059
|
+
'aria-label': '删除选中的标注',
|
|
9060
|
+
title: '删除选中的标注(Delete / Backspace)',
|
|
9061
|
+
'data-utb-annot-action': 'delete-selection',
|
|
9062
|
+
'data-utb-annot-selection': String(annotSelectionCount()),
|
|
9063
|
+
disabled: !annotReady,
|
|
9064
|
+
onClick: () => annotDeleteSelection(),
|
|
9065
|
+
}, '删除选中(' + annotSelectionCount() + ')'),
|
|
8384
9066
|
h('button', {
|
|
8385
9067
|
type: 'button',
|
|
8386
9068
|
className: 'utb-btn utb-annot-clear',
|
|
@@ -8808,6 +9490,8 @@ h('div', { className: 'utb-bar' },
|
|
|
8808
9490
|
'data-utb-annot-space': 'canvas-css-px',
|
|
8809
9491
|
'data-utb-annot-sync': annotSyncState,
|
|
8810
9492
|
'data-utb-annot-revision': String(annotSyncRevision),
|
|
9493
|
+
'data-utb-annot-active-tool': annotTool,
|
|
9494
|
+
'data-utb-annot-selection-count': String(annotSelectionCount()),
|
|
8811
9495
|
role: 'img',
|
|
8812
9496
|
'aria-label': '标注画布',
|
|
8813
9497
|
tabIndex: annotOn ? 0 : -1,
|
|
@@ -8825,7 +9509,12 @@ h('div', { className: 'utb-bar' },
|
|
|
8825
9509
|
zIndex: 2,
|
|
8826
9510
|
display: annotMapNow ? 'block' : 'none',
|
|
8827
9511
|
pointerEvents: annotOn ? 'auto' : 'none',
|
|
8828
|
-
cursor: annotOn
|
|
9512
|
+
cursor: annotOn
|
|
9513
|
+
? (annotMoveRef.current.active && annotMoveRef.current.dragging ? 'grabbing'
|
|
9514
|
+
: annotTool === 'select' || annotTool === 'marquee' ? annotHoverRef.current
|
|
9515
|
+
: annotTool === 'text' ? 'text'
|
|
9516
|
+
: 'crosshair')
|
|
9517
|
+
: 'default',
|
|
8829
9518
|
touchAction: 'none',
|
|
8830
9519
|
userSelect: 'none',
|
|
8831
9520
|
},
|
|
@@ -8834,9 +9523,46 @@ h('div', { className: 'utb-bar' },
|
|
|
8834
9523
|
onPointerUp: onAnnotPointerUp,
|
|
8835
9524
|
onPointerCancel: onAnnotPointerCancel,
|
|
8836
9525
|
onLostPointerCapture: onAnnotPointerCancel,
|
|
9526
|
+
onPointerLeave: () => annotSetHoverCursor('default'),
|
|
8837
9527
|
},
|
|
8838
|
-
|
|
8839
|
-
|
|
9528
|
+
// 选中光晕先画:同一几何用品牌色加粗描边垫在图形下面,图形本体仍保持原色。
|
|
9529
|
+
annotVisibleShapes.map((shape) => (annotSelectionRef.current.has(shape.id)
|
|
9530
|
+
? renderAnnotShape(shape, {
|
|
9531
|
+
key: 'annot-halo-' + shape.id,
|
|
9532
|
+
className: 'utb-annot-select-halo',
|
|
9533
|
+
'data-utb-annot-halo': shape.id,
|
|
9534
|
+
'data-utb-annot-state': 'halo',
|
|
9535
|
+
'data-utb-annot-selected': 'true',
|
|
9536
|
+
})
|
|
9537
|
+
: null)),
|
|
9538
|
+
annotVisibleShapes.map((shape) => renderAnnotShape(shape, {
|
|
9539
|
+
'data-utb-annot-selected': annotSelectionRef.current.has(shape.id) ? 'true' : 'false',
|
|
9540
|
+
})),
|
|
9541
|
+
// 选中态:每个选中图形一组「行进蚂蚁」框(白底 + 蓝色虚线 + 蓝色淡填充),
|
|
9542
|
+
// 保证在浅色/深色页面上都能一眼看出;纯面板视觉,不进 Host 截图、不改图形数据。
|
|
9543
|
+
annotVisibleShapes.map((shape) => {
|
|
9544
|
+
if (!annotSelectionRef.current.has(shape.id)) return null;
|
|
9545
|
+
const box = annotShapeBounds(shape);
|
|
9546
|
+
if (!box) return null;
|
|
9547
|
+
const rect = { x: box.x, y: box.y, width: box.w, height: box.h, rx: 2 };
|
|
9548
|
+
return h('g', {
|
|
9549
|
+
key: 'annot-select-' + shape.id,
|
|
9550
|
+
className: 'utb-annot-select-group',
|
|
9551
|
+
'data-utb-annot-select-box': shape.id,
|
|
9552
|
+
},
|
|
9553
|
+
h('rect', Object.assign({}, rect, { className: 'utb-annot-select-fill' })),
|
|
9554
|
+
h('rect', Object.assign({}, rect, { className: 'utb-annot-select-edge' })),
|
|
9555
|
+
h('rect', Object.assign({}, rect, { className: 'utb-annot-select-ants' })));
|
|
9556
|
+
}),
|
|
9557
|
+
annotSelectionCount() > 1 && (() => {
|
|
9558
|
+
const box = annotSelectionBounds();
|
|
9559
|
+
if (!box) return null;
|
|
9560
|
+
const rect = { x: box.x, y: box.y, width: box.w, height: box.h, rx: 2 };
|
|
9561
|
+
return h('g', { key: 'annot-group-bounds', className: 'utb-annot-select-group', 'data-utb-annot-group-box': 'true' },
|
|
9562
|
+
h('rect', Object.assign({}, rect, { className: 'utb-annot-select-edge' })),
|
|
9563
|
+
h('rect', Object.assign({}, rect, { className: 'utb-annot-select-ants' })));
|
|
9564
|
+
})(),
|
|
9565
|
+
h('g', { key: 'annot-draft', 'data-utb-annot-state': 'draft', ref: annotDraftRef })),
|
|
8840
9566
|
annotText ? h('input', {
|
|
8841
9567
|
key: 'annot-text-input-' + annotText.id,
|
|
8842
9568
|
className: 'utb-annot-text-input',
|
|
@@ -8899,6 +9625,14 @@ h('div', { className: 'utb-bar' },
|
|
|
8899
9625
|
MODE_LABEL[remote.mode === 'agent' ? 'agent' : mode]),
|
|
8900
9626
|
h('span', { className: 'utb-sep', 'aria-hidden': true }),
|
|
8901
9627
|
h('span', { className: 'utb-perf', title: `实际显示帧率与端到端显示延迟(测量值);传输:${transportKind === 'ws' ? 'WebSocket 推送' : 'HTTP 轮询'}` }, `${fps} FPS ${frameMs} ms`),
|
|
9628
|
+
// 选中数量是状态信息:放在底部延迟之后,而不是标注栏里的按钮/胶囊。
|
|
9629
|
+
annotSelectionCount() > 0 && h('span', {
|
|
9630
|
+
className: 'utb-annot-selected-status',
|
|
9631
|
+
role: 'status',
|
|
9632
|
+
'aria-live': 'polite',
|
|
9633
|
+
'data-utb-annot-selection-chip': String(annotSelectionCount()),
|
|
9634
|
+
title: '已选标注:拖动选框内任意位置移动,Delete / Backspace 删除,Esc 取消选择',
|
|
9635
|
+
}, '已选 ' + annotSelectionCount() + ' 个'),
|
|
8902
9636
|
// v2 §11.3:标注同步状态(title + aria-live='polite'),不弹 modal、不写页面 lastError。
|
|
8903
9637
|
h('span', {
|
|
8904
9638
|
className: 'utb-annot-sync',
|