dsh-drawai 0.3.3 → 0.3.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.
Files changed (2) hide show
  1. package/lib/client.js +208 -42
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -3523,6 +3523,104 @@ window.__ModuleLoader__.load({
3523
3523
  * 3. **进出形状的最后一段垂直于落点所在的边**(从左边进就横着进)。
3524
3524
  * 否则线会贴着边框斜切进去,看起来像画错了。
3525
3525
  */
3526
+ /** 修复手动路径的一跳:保留两端必经点,在节点边界外的正交网格上寻路。
3527
+ * 正常的 L 形不进此分支;方向作为状态,禁止原路折返,代价先比转弯再比长度。 */
3528
+ function avoidOrthogonalLeg(a, b, boxes, before, next, sourceSide, targetSide) {
3529
+ const obstacles = Array.isArray(boxes) ? boxes : []
3530
+ const xs = new Set([a.x, b.x, a.x - EDGE_GRID, a.x + EDGE_GRID, b.x - EDGE_GRID, b.x + EDGE_GRID])
3531
+ const ys = new Set([a.y, b.y, a.y - EDGE_GRID, a.y + EDGE_GRID, b.y - EDGE_GRID, b.y + EDGE_GRID])
3532
+ for (const box of obstacles) {
3533
+ xs.add(Math.floor((box.geo.x - 8) / EDGE_GRID) * EDGE_GRID)
3534
+ xs.add(Math.ceil((box.geo.x + box.geo.w + 8) / EDGE_GRID) * EDGE_GRID)
3535
+ ys.add(Math.floor((box.geo.y - 8) / EDGE_GRID) * EDGE_GRID)
3536
+ ys.add(Math.ceil((box.geo.y + box.geo.h + 8) / EDGE_GRID) * EDGE_GRID)
3537
+ }
3538
+ const xx = [...xs].sort((x, y) => x - y)
3539
+ const yy = [...ys].sort((x, y) => x - y)
3540
+ const width = xx.length
3541
+ const pointAt = (v) => ({ x: xx[v % width], y: yy[Math.floor(v / width)] })
3542
+ // e/s/w/n;相反方向差 2。
3543
+ const direction = (p, q) => p.x === q.x ? (q.y > p.y ? 1 : 3) : (q.x > p.x ? 0 : 2)
3544
+ const sideDirection = { e: 0, s: 1, w: 2, n: 3 }
3545
+ const start = yy.indexOf(a.y) * width + xx.indexOf(a.x)
3546
+ const goal = yy.indexOf(b.y) * width + xx.indexOf(b.x)
3547
+ const incoming = before === null ? 4 : direction(before, a)
3548
+ const outgoing = next !== null && (next.x === b.x || next.y === b.y) && (next.x !== b.x || next.y !== b.y) ? direction(b, next) : 4
3549
+ const costs = new Map()
3550
+ const parents = new Map()
3551
+ const heap = []
3552
+ const better = (x, y) => x.bends < y.bends || (x.bends === y.bends && x.length < y.length)
3553
+ const push = (item) => {
3554
+ heap.push(item)
3555
+ let i = heap.length - 1
3556
+ while (i > 0) {
3557
+ const parent = (i - 1) >> 1
3558
+ if (!better(item, heap[parent])) break
3559
+ heap[i] = heap[parent]
3560
+ i = parent
3561
+ }
3562
+ heap[i] = item
3563
+ }
3564
+ const pop = () => {
3565
+ const first = heap[0]
3566
+ const last = heap.pop()
3567
+ if (heap.length > 0) {
3568
+ let i = 0
3569
+ while (i * 2 + 1 < heap.length) {
3570
+ let child = i * 2 + 1
3571
+ if (child + 1 < heap.length && better(heap[child + 1], heap[child])) child += 1
3572
+ if (!better(heap[child], last)) break
3573
+ heap[i] = heap[child]
3574
+ i = child
3575
+ }
3576
+ heap[i] = last
3577
+ }
3578
+ return first
3579
+ }
3580
+ const initial = { key: start * 5 + incoming, bends: 0, length: 0 }
3581
+ costs.set(initial.key, initial)
3582
+ push(initial)
3583
+ const blocked = new Map()
3584
+ while (heap.length > 0) {
3585
+ const cur = pop()
3586
+ if (costs.get(cur.key) !== cur) continue
3587
+ const v = Math.floor(cur.key / 5)
3588
+ const dir = cur.key % 5
3589
+ if (v === goal) {
3590
+ const path = []
3591
+ let key = cur.key
3592
+ while (key !== undefined) {
3593
+ path.push(pointAt(Math.floor(key / 5)))
3594
+ key = parents.get(key)
3595
+ }
3596
+ return simplifyCollinear(path.reverse())
3597
+ }
3598
+ const x = v % width
3599
+ const y = Math.floor(v / width)
3600
+ const neighbors = [x + 1 < width ? v + 1 : -1, y + 1 < yy.length ? v + width : -1, x > 0 ? v - 1 : -1, y > 0 ? v - width : -1]
3601
+ const p = pointAt(v)
3602
+ for (let d = 0; d < 4; d += 1) {
3603
+ const nv = neighbors[d]
3604
+ if (nv < 0 || (dir < 4 && (dir + 2) % 4 === d)) continue
3605
+ if (v === start && sourceSide && d !== sideDirection[sourceSide]) continue
3606
+ if (nv === goal && targetSide && d !== (sideDirection[targetSide] + 2) % 4) continue
3607
+ if (nv === goal && outgoing < 4 && (d + 2) % 4 === outgoing) continue
3608
+ const q = pointAt(nv)
3609
+ const edgeKey = Math.min(v, nv) + ':' + Math.max(v, nv)
3610
+ if (!blocked.has(edgeKey)) blocked.set(edgeKey, obstacles.some((box) => segmentHitsBox(p, q, box.geo, 0)))
3611
+ if (blocked.get(edgeKey)) continue
3612
+ const candidate = { key: nv * 5 + d, bends: cur.bends + (dir < 4 && dir !== d ? 1 : 0), length: cur.length + Math.abs(q.x - p.x) + Math.abs(q.y - p.y) }
3613
+ const known = costs.get(candidate.key)
3614
+ if (known !== undefined && !better(candidate, known)) continue
3615
+ costs.set(candidate.key, candidate)
3616
+ parents.set(candidate.key, cur.key)
3617
+ push(candidate)
3618
+ }
3619
+ }
3620
+ // 节点互相覆盖、必经点不可达时保留原路径,不能让连线消失。
3621
+ return null
3622
+ }
3623
+
3526
3624
  function routeThroughWaypoints(fromBox, toBox, waypoints, obstacleBoxes, userWaypoints) {
3527
3625
  // 折点可能落在某个节点**内部**(用户拖到节点上、或节点移动后把它吞进去了)。
3528
3626
  // 照直连过去线就穿进节点里,所以先推到外面 —— 只改路由用的副本,不动用户数据。
@@ -3541,14 +3639,30 @@ window.__ModuleLoader__.load({
3541
3639
  // 最后的落点先算出来(它只取决于最后一个折点):它也是最后一个折点的"下一站",
3542
3640
  // 传给 connectOrtho 用于判断"到站方向会不会被下一段顶回来"。
3543
3641
  const end = borderPointToward(toBox.geo, safe[safe.length - 1])
3642
+ const append = (target, next, sourceSide, targetSide) => {
3643
+ const offset = pts.length - 1
3644
+ const before = offset > 0 ? pts[offset - 1] : null
3645
+ axis = connectOrtho(pts, prev, target, axis, next)
3646
+ const leg = pts.slice(offset)
3647
+ const test = (before === null ? [] : [before]).concat(leg, next === null ? [] : [next])
3648
+ const hits = leg.some((p, i) => i > 0 && Array.isArray(obstacleBoxes) && obstacleBoxes.some((box) => segmentHitsBox(leg[i - 1], p, box.geo, 0)))
3649
+ const fold = test.some((p, i) => i > 1 && isFoldApex(test[i - 2], test[i - 1], p))
3650
+ const wrongSide = leg.length > 1 && ((sourceSide && !leavesSide(leg[0], leg[1], sourceSide)) || (targetSide && !leavesSide(leg[leg.length - 1], leg[leg.length - 2], targetSide)))
3651
+ if (Array.isArray(obstacleBoxes) && leg.length > 1 && (hits || fold || wrongSide)) {
3652
+ const repaired = avoidOrthogonalLeg(prev, target, obstacleBoxes, before, next, sourceSide, targetSide)
3653
+ if (repaired !== null) pts.splice(offset + 1, pts.length - offset - 1, ...repaired.slice(1))
3654
+ }
3655
+ prev = target
3656
+ }
3544
3657
  for (let i = 0; i < safe.length; i += 1) {
3545
3658
  const next = i + 1 < safe.length ? safe[i + 1] : end
3546
- axis = connectOrtho(pts, prev, safe[i], axis, next)
3547
- prev = safe[i]
3659
+ const atEnd = i === safe.length - 1 && Math.abs(safe[i].x - end.x) < 0.5 && Math.abs(safe[i].y - end.y) < 0.5
3660
+ append(safe[i], next, pts.length === 1 ? start.side : null, atEnd ? end.side : null)
3548
3661
  }
3549
3662
  // 落点在左右边 → 最后一段要横着进 → 先竖后横,所以传 'v';上下边反之。
3550
3663
  const approach = end.side === 'e' || end.side === 'w' ? 'v' : 'h'
3551
- connectOrtho(pts, prev, end, approach)
3664
+ axis = approach
3665
+ append(end, null, pts.length === 1 ? start.side : null, end.side)
3552
3666
  return pts
3553
3667
  }
3554
3668
 
@@ -3559,6 +3673,14 @@ window.__ModuleLoader__.load({
3559
3673
  return finalizePath(build(start), safe, keep)
3560
3674
  }
3561
3675
 
3676
+ /** 从节点侧面沿外法线离开,禁止贴边走或反向穿进节点。 */
3677
+ function leavesSide(a, b, side) {
3678
+ if (side === 'e') return Math.abs(a.y - b.y) < 0.5 && b.x > a.x
3679
+ if (side === 'w') return Math.abs(a.y - b.y) < 0.5 && b.x < a.x
3680
+ if (side === 's') return Math.abs(a.x - b.x) < 0.5 && b.y > a.y
3681
+ return Math.abs(a.x - b.x) < 0.5 && b.y < a.y
3682
+ }
3683
+
3562
3684
  /** 从 safe(已推出的折点序列,含桩点)里挑出"对应人摆的那些点"。
3563
3685
  *
3564
3686
  * 按**坐标**配对而不是按下标:桩点与折点在 chain 里混在一起,下标要另算一遍首尾桩点个数,
@@ -3613,12 +3735,23 @@ window.__ModuleLoader__.load({
3613
3735
 
3614
3736
  /** 在第 segmentIndex 段上插新折点时,它排在 points[] 的第几位:数该段起点之前有几个折点。 */
3615
3737
  function insertIndexForPath(pts, waypoints, segmentIndex) {
3616
- let count = 0
3738
+ // 共线化简后,已有折点可能位于线段内部,不能只查路径顶点。
3739
+ const position = (point) => {
3740
+ for (let i = 0; i < pts.length - 1; i += 1) {
3741
+ const a = pts[i]
3742
+ const b = pts[i + 1]
3743
+ const length = Math.abs(b.x - a.x) + Math.abs(b.y - a.y)
3744
+ const before = Math.abs(point.x - a.x) + Math.abs(point.y - a.y)
3745
+ const after = Math.abs(point.x - b.x) + Math.abs(point.y - b.y)
3746
+ if (length > 0 && Math.abs(before + after - length) < 0.5) return i + before / length
3747
+ }
3748
+ return -1
3749
+ }
3617
3750
  for (let i = 0; i < waypoints.length; i += 1) {
3618
- const at = pathIndexOf(pts, waypoints[i])
3619
- if (at >= 0 && at <= segmentIndex) count += 1
3751
+ const at = position(waypoints[i])
3752
+ if (at > segmentIndex + 1) return i
3620
3753
  }
3621
- return count
3754
+ return waypoints.length
3622
3755
  }
3623
3756
 
3624
3757
  /**
@@ -3637,6 +3770,53 @@ window.__ModuleLoader__.load({
3637
3770
  return at
3638
3771
  }
3639
3772
 
3773
+ /** 被化简到段内部的折点也属于整段,必须和两个端点使用同一位移。 */
3774
+ function movePinnedSegment(points, a, b, move) {
3775
+ return points.map((p, i) => ({
3776
+ x: p.x + (i >= a && i <= b ? move.x : 0),
3777
+ y: p.y + (i >= a && i <= b ? move.y : 0),
3778
+ }))
3779
+ }
3780
+
3781
+ /** 段拖动的快照。真实连接端点属于节点,不作为随段移动的用户折点。
3782
+ * 首末段在引出点处截断:平移后只需一条横向/纵向连接,避免先抵达边框再倒回桩点。 */
3783
+ function prepareSegmentDrag(doc, edge, pts, index) {
3784
+ if (pts === null || index < 0 || index >= pts.length - 1) return null
3785
+ const path = pts.map((p) => ({ x: p.x, y: p.y }))
3786
+ let points = Array.isArray(edge.points) ? edge.points.map((p) => ({ x: p.x, y: p.y })) : []
3787
+ const geometry = buildGeometry(doc)
3788
+ const sides = sidesFromStyle(edge.style)
3789
+ const trim = (at, neighbor, end) => {
3790
+ // 悬空端维持原有语义;只有真实节点上的连接端点需要保留固定引出段。
3791
+ const id = end === 'source' ? edge.from : edge.to
3792
+ const box = geometry.byId[id]
3793
+ if (box === undefined) return
3794
+ const border = path[at]
3795
+ const other = path[neighbor]
3796
+ const length = Math.abs(other.x - border.x) + Math.abs(other.y - border.y)
3797
+ if (length < 0.5) return
3798
+ const side = sides[end] || borderPointToward(box.geo, other).side
3799
+ if (!leavesSide(border, other, side)) return
3800
+ const jetty = Number.isFinite(sides.jetty) && sides.jetty > 0 ? sides.jetty : 24
3801
+ const inset = Math.min(jetty, length)
3802
+ const proxy = { x: border.x + (other.x - border.x) * inset / length, y: border.y + (other.y - border.y) * inset / length }
3803
+ // 去掉固定引出段内部的旧折点(包含过去误钉下的边框端点),不让它们再随段移动。
3804
+ points = points.filter((p) => {
3805
+ if (Math.abs(p.x - proxy.x) < 0.5 && Math.abs(p.y - proxy.y) < 0.5) return true
3806
+ const distance = Math.abs(p.x - border.x) + Math.abs(p.y - border.y)
3807
+ const rest = Math.abs(p.x - proxy.x) + Math.abs(p.y - proxy.y)
3808
+ return Math.abs(distance + rest - inset) >= 0.5
3809
+ })
3810
+ path[at] = proxy
3811
+ }
3812
+ if (index === 0) trim(0, 1, 'source')
3813
+ if (index === path.length - 2) trim(path.length - 1, path.length - 2, 'target')
3814
+ const a = ensurePinned(path, points, index)
3815
+ const b = ensurePinned(path, points, index + 1)
3816
+ if (a < 0 || b < 0) return null
3817
+ return { base: points, a: a, b: b, horizontal: Math.abs(pts[index].y - pts[index + 1].y) < 0.5 }
3818
+ }
3819
+
3640
3820
  /**
3641
3821
  * 连线路径的唯一入口。
3642
3822
  *
@@ -3901,7 +4081,17 @@ window.__ModuleLoader__.load({
3901
4081
  const index = applyRouteHysteresis(raw, picked, hint)
3902
4082
  // 自动候选理论上不会折返,这里照样过一遍:折返在画面上只有坏处(重合线 + 尖点鼓包),
3903
4083
  // 而"每条边上都没有折返"是能在自测里断言的硬不变量。
3904
- return simplifyCollinear(removeRetraces(dedupePoints(raw[index].points)))
4084
+ const path = simplifyCollinear(removeRetraces(dedupePoints(raw[index].points)))
4085
+ // 六个固定候选不一定覆盖可行通道;不能在它们全被挡住时直接选一条穿模的线。
4086
+ if (path.some((p, i) => i > 0 && boxes.some((box) => segmentHitsBox(path[i - 1], p, box.geo, 0)))) {
4087
+ const start = path[0]
4088
+ const end = path[path.length - 1]
4089
+ const sourceSide = from.w > 0 && from.h > 0 ? borderPointToward(from, start).side : null
4090
+ const targetSide = to.w > 0 && to.h > 0 ? borderPointToward(to, end).side : null
4091
+ const repaired = avoidOrthogonalLeg(start, end, boxes, null, null, sourceSide, targetSide)
4092
+ if (repaired !== null) return repaired
4093
+ }
4094
+ return path
3905
4095
  }
3906
4096
 
3907
4097
 
@@ -10446,16 +10636,7 @@ window.__ModuleLoader__.load({
10446
10636
  for (let i = 0; i < next.edges.length; i += 1) {
10447
10637
  const e = next.edges[i]
10448
10638
  if (e.id !== edgeDrag.edgeId) continue
10449
- const points = edgeDrag.base.map((p) => ({ x: p.x, y: p.y }))
10450
- if (edgeDrag.a < points.length) {
10451
- points[edgeDrag.a].x += moveX
10452
- points[edgeDrag.a].y += moveY
10453
- }
10454
- if (edgeDrag.b < points.length) {
10455
- points[edgeDrag.b].x += moveX
10456
- points[edgeDrag.b].y += moveY
10457
- }
10458
- e.points = points
10639
+ e.points = movePinnedSegment(edgeDrag.base, edgeDrag.a, edgeDrag.b, { x: moveX, y: moveY })
10459
10640
  break
10460
10641
  }
10461
10642
  }, 'segment:' + edgeDrag.edgeId + ':' + edgeDrag.a + ':' + edgeDrag.b)
@@ -11304,39 +11485,22 @@ window.__ModuleLoader__.load({
11304
11485
  return
11305
11486
  }
11306
11487
 
11307
- const points = Array.isArray(edge.points) ? edge.points.slice() : []
11308
11488
  if (kind === 'segment') {
11309
11489
  // 拖动某一段 = **整段平移**(draw.io 的段把手语义),而不是插入一个自由折点。
11310
11490
  //
11311
- // 做法:先把这一段的两个端点就地钉成折点(在原位补折点不改变路径,所以这一刻画面不动),
11312
- // 之后同时平移这两个折点,这一段就整体挪走了。
11313
- //
11314
- // 端点落在起点/终点一侧时同样成立:那个顶点被钉住之后,形状的落点会朝它重新计算,
11315
- // 于是自动多出一小段连接 —— 也就是"相当于新建一条线段"。
11491
+ // 连接端点保持固定,只钉住可移动区间;按下仅保存快照,实际移动后才改文档。
11316
11492
  const pts = edgeRoutePoints(current, edge, routeMemoryRef.current)
11317
- if (pts === null || index < 0 || index >= pts.length - 1) return
11318
- const a = pts[index]
11319
- const b = pts[index + 1]
11320
- const pinA = ensurePinned(pts, points, index)
11321
- const pinB = ensurePinned(pts, points, index + 1)
11322
- if (pinA < 0 || pinB < 0 || pinA === pinB) return
11323
- const saved = points.map((p) => ({ x: p.x, y: p.y }))
11324
- applyLocal((next) => {
11325
- for (let i = 0; i < next.edges.length; i += 1) {
11326
- if (next.edges[i].id !== edgeId) continue
11327
- next.edges[i].points = saved.map((p) => ({ x: p.x, y: p.y }))
11328
- break
11329
- }
11330
- })
11493
+ const seed = prepareSegmentDrag(current, edge, pts, index)
11494
+ if (seed === null) return
11331
11495
  const origin = toUserSpace(event)
11332
11496
  if (origin === null) return
11333
11497
  edgeDragRef.current = {
11334
11498
  edgeId: edgeId,
11335
11499
  kind: 'segment',
11336
- a: pinA,
11337
- b: pinB,
11338
- horizontal: Math.abs(a.y - b.y) < 0.5,
11339
- base: saved,
11500
+ a: seed.a,
11501
+ b: seed.b,
11502
+ horizontal: seed.horizontal,
11503
+ base: seed.base,
11340
11504
  originX: origin.x,
11341
11505
  originY: origin.y,
11342
11506
  }
@@ -15420,6 +15584,8 @@ window.__ModuleLoader__.load({
15420
15584
  drawaiPaneOf: drawaiPaneOf,
15421
15585
  clearStaleTextSelection: clearStaleTextSelection,
15422
15586
  ensurePinned: ensurePinned,
15587
+ movePinnedSegment: movePinnedSegment,
15588
+ prepareSegmentDrag: prepareSegmentDrag,
15423
15589
  SIDES: SIDES,
15424
15590
  HOT_PAD: HOT_PAD,
15425
15591
  GRID: GRID,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-drawai",
3
- "version": "0.3.3",
3
+ "version": "0.3.4",
4
4
  "type": "module",
5
5
  "description": "DSH 右侧栏的可编辑画布 + AI 语义绘图工具(diagram_apply / diagram_read),直接读写原生 .drawio 文件",
6
6
  "license": "MIT",