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/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-11T16:20+08:00·address-history+go';
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:v1';
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
- return addressHistoryNormalize(JSON.parse(storage.getItem(ADDRESS_HISTORY_KEY) || '[]'), limit);
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(ADDRESS_HISTORY_KEY, JSON.stringify(addressHistoryNormalize(list)));
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
- const [addressHistory, setAddressHistory] = React.useState(() => addressHistoryRead(addressHistoryStorage()));
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
- annotApplyImported(accepted.shapes);
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) return false;
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 && point.y >= box.y && point.y <= box.y + box.height;
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') return annotHitArrow(shape, point, radius);
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
- function renderAnnotShape(shape) {
5511
- const common = {
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: Esc order (composition → editor/draft cancel → exit) and the hard
7382
- // capability/lifecycle gate. Both listeners live only while the mode is on.
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
- return () => document.removeEventListener('keydown', onKeyDown, true);
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 ? (annotTool === 'text' ? 'text' : 'crosshair') : 'default',
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
- annotVisibleShapes.map(renderAnnotShape),
8839
- h('g', { key: 'annot-draft', 'data-utb-annot-state': 'draft', ref: annotDraftRef })),
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',