dsh-drawai 0.2.3 → 0.2.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 +124 -25
  2. package/package.json +1 -1
package/lib/client.js CHANGED
@@ -1258,7 +1258,7 @@ window.__ModuleLoader__.load({
1258
1258
  // `height:100%` 要求父级有**确定高度**,而宿主给的容器不保证这一点。
1259
1259
  // inset:0 只要求父级是定位元素,而 .drawai-pane 已经是(我们自己的 CSS),
1260
1260
  // 所以这条链子只依赖我们自己,不再依赖宿主怎么排。
1261
- '.drawai-root{display:flex;flex-direction:column;position:absolute;top:0;left:0;right:0;bottom:0;min-height:0;font-size:12px;color:var(--dsw-alias-label-primary,#e6e6e6)}',
1261
+ '.drawai-root{display:flex;flex-direction:column;position:absolute;top:0;left:0;right:0;bottom:0;min-height:0;font-size:12px;color:var(--dsw-alias-label-primary,#e6e6e6);outline:none}',
1262
1262
  '.drawai-head{display:flex;align-items:center;gap:6px;padding:6px 8px;border-bottom:1px solid var(--dsw-alias-border-l1,#333)}',
1263
1263
  // 多画布标签条。放在工具条**下面**一行:工具条是"对这张画布做什么",
1264
1264
  // 标签条是"现在看哪张画布",两者职责不同,混在一行会挤成一团(右栏本来就窄)。
@@ -5832,15 +5832,16 @@ window.__ModuleLoader__.load({
5832
5832
  /**
5833
5833
  * 画布该不该处理这次按键。
5834
5834
  *
5835
- * 画布的快捷键挂在 `window` 上(这样"点过画布之后"不用先把焦点放进某个元素里),
5836
- * 代价是**别的面板里的按键也会经过这里** —— 而 DSH 的输入框就在同一个页面里。
5837
- * 两条判据,任何一条成立就放手:
5835
+ * 画布的快捷键挂在 `window` 上,代价是**别的面板里的按键也会经过这里** ——
5836
+ * 而 DSH 的输入框就在同一个页面里。两条判据,任何一条成立就放手:
5838
5837
  *
5839
5838
  * 1. 事件目标或当前焦点是**输入宿主**(见 isTextEntry)→ 那是人家在打字;
5840
5839
  * 2. 焦点已经落在**别的控件**上(另一个面板的按钮、链接、树节点…)→ 也不抢。
5841
- * 只有"焦点没了"(刚点过画布空白,焦点回到 body)或"焦点就在本面板里"才算画布的。
5840
+ * 只有"焦点没了"(焦点回到 body)或"焦点就在本面板里"才算画布的。
5842
5841
  *
5843
- * 第 2 条是为了防同类毛病:焦点在侧边栏某个按钮上时按退格,同样不该删画布里的东西。
5842
+ * **DSH 0.2 起不能再假设"点过画布焦点就回 body"**:对话是 Lexical contenteditable,
5843
+ * 点 SVG/画布空白常常**抢不走它的焦点**,于是这里一直判 false —— Delete / Ctrl+C/V
5844
+ * 全无反应。所以 `onRootPointerDown` 会主动把焦点收到 `.drawai-root`(tabIndex=-1)。
5844
5845
  */
5845
5846
  function canvasOwnsKeyboard(event, root, active) {
5846
5847
  const target = event === null || event === undefined ? null : event.target
@@ -5855,6 +5856,29 @@ window.__ModuleLoader__.load({
5855
5856
  return root.contains(active) === true
5856
5857
  }
5857
5858
 
5859
+ /**
5860
+ * 把键盘焦点收到画布面板根上(不抢就地编辑 / 菜单输入框)。
5861
+ *
5862
+ * 见 canvasOwnsKeyboard:DSH 0.2 点画布后焦点常仍停在对话 contenteditable,
5863
+ * 不主动 focus 的话快捷键整组静默失效。
5864
+ */
5865
+ function claimCanvasKeyboard(root, target) {
5866
+ if (root === null || root === undefined || typeof root.focus !== 'function') return
5867
+ if (isTextEntry(target)) return
5868
+ if (target !== null && target !== undefined && typeof target.closest === 'function') {
5869
+ if (target.closest('.drawai-edit') !== null) return
5870
+ }
5871
+ try {
5872
+ if (typeof root.focus === 'function') root.focus({ preventScroll: true })
5873
+ } catch (error) {
5874
+ try {
5875
+ root.focus()
5876
+ } catch (error2) {
5877
+ // 测试环境 / 隐藏节点:静默
5878
+ }
5879
+ }
5880
+ }
5881
+
5858
5882
  /**
5859
5883
  * 剪贴板:**模块级**(不属于某个标签页)—— 在一个标签页里复制、切到另一个标签页粘贴,
5860
5884
  * 是画布的常规用法,做成每个标签页一份反而奇怪。
@@ -8992,6 +9016,18 @@ window.__ModuleLoader__.load({
8992
9016
  if (count === 0) setSaveNote('锁定层上的单元移不动:先解锁图层')
8993
9017
  }
8994
9018
 
9019
+ /**
9020
+ * 快捷键字母是否匹配(兼容 event.key 与 event.code)。
9021
+ * 某些布局 / IME 下 Ctrl+C 的 key 不是可靠的 'c',code 仍是 KeyC。
9022
+ */
9023
+ function accelLetter(event, letter) {
9024
+ if (event === null || event === undefined) return false
9025
+ const upper = String(letter).toUpperCase()
9026
+ if (event.code === 'Key' + upper) return true
9027
+ const key = event.key
9028
+ return key === letter || key === upper
9029
+ }
9030
+
8995
9031
  React.useEffect(() => {
8996
9032
  function onKey(event) {
8997
9033
  // 归属判定见 canvasOwnsKeyboard:**在别处打字时这里一律不动手**。
@@ -9009,44 +9045,51 @@ window.__ModuleLoader__.load({
9009
9045
  // 半路踩过的坑:**画布里有选中元素时不能放手**。用户先在对话里划了字、再点画布上的
9010
9046
  // 元素,那次点击不会清掉别处的选区,于是"外部有选区"成立、Ctrl+C 被让给浏览器,
9011
9047
  // 画布里的元素就**复制不了**了。所以判据是两条同时成立:画布空着 **且** 外面有选中的字。
9012
- if (accel && (event.key === 'c' || event.key === 'C' || event.key === 'x' || event.key === 'X' || event.key === 'a' || event.key === 'A')) {
9048
+ if (accel && (accelLetter(event, 'c') || accelLetter(event, 'x') || accelLetter(event, 'a'))) {
9013
9049
  if (selectedIds.length === 0 && hasTextSelectionOutside(rootRef.current)) return
9014
9050
  }
9015
- if (accel && (event.key === 'z' || event.key === 'Z')) {
9051
+ if (accel && accelLetter(event, 'z')) {
9016
9052
  event.preventDefault()
9017
9053
  if (event.shiftKey === true) redo()
9018
9054
  else undo()
9019
9055
  return
9020
9056
  }
9021
- if (accel && (event.key === 'y' || event.key === 'Y')) {
9057
+ if (accel && accelLetter(event, 'y')) {
9022
9058
  event.preventDefault()
9023
9059
  redo()
9024
9060
  return
9025
9061
  }
9026
- // 复制 / 剪切 / 粘贴:与 drawio 同一套快捷键(Ctrl+C / Ctrl+X / Ctrl+V)。
9027
- // 必须挡在输入框之外 —— 上面的归属判定已经保证在改标签时不会走到这里。
9028
- if (accel && (event.key === 'c' || event.key === 'C')) {
9062
+ // 复制 / 剪切:keydown 是兜底;主通道是下面的 copy/cut 捕获监听
9063
+ // (DSH 0.2 有时在冒泡阶段先截走 Ctrl+C,keydown 冒泡听不到)。
9064
+ if (accel && accelLetter(event, 'c')) {
9029
9065
  event.preventDefault()
9066
+ event.stopPropagation()
9030
9067
  copySelection()
9031
9068
  return
9032
9069
  }
9033
- if (accel && (event.key === 'x' || event.key === 'X')) {
9070
+ if (accel && accelLetter(event, 'x')) {
9034
9071
  event.preventDefault()
9072
+ event.stopPropagation()
9035
9073
  cutSelection()
9036
9074
  return
9037
9075
  }
9038
- // Ctrl+V:**不要**在 keydown 里 preventDefault —— 否则 paste 事件带不进系统剪贴板里的图片。
9039
- // 画布内节点粘贴与系统图片粘贴都在下面的 paste 监听里处理。
9040
- if (accel && (event.key === 'v' || event.key === 'V')) {
9076
+ // Ctrl+V:有画布内剪贴板时直接粘贴(不要等 paste 事件 —— 焦点若在中间被抢走会丢)。
9077
+ // 剪贴板空时不 preventDefault,留给 paste 事件接系统图片。
9078
+ if (accel && accelLetter(event, 'v')) {
9079
+ if (clipboard !== null) {
9080
+ event.preventDefault()
9081
+ event.stopPropagation()
9082
+ pasteClipboard()
9083
+ }
9041
9084
  return
9042
9085
  }
9043
9086
  // 全选:与框选同一套语义(节点与连线都选上),于是能整体拖动/换样式/一把删掉。
9044
- if (accel && (event.key === 'a' || event.key === 'A')) {
9087
+ if (accel && accelLetter(event, 'a')) {
9045
9088
  event.preventDefault()
9046
9089
  selectAll()
9047
9090
  return
9048
9091
  }
9049
- if (event.key === 'Delete' || event.key === 'Backspace') {
9092
+ if (event.key === 'Delete' || event.key === 'Backspace' || event.code === 'Delete' || event.code === 'Backspace') {
9050
9093
  event.preventDefault()
9051
9094
  deleteSelected()
9052
9095
  return
@@ -9056,12 +9099,12 @@ window.__ModuleLoader__.load({
9056
9099
  nudgeSelectionByKey(event.key, event.shiftKey === true)
9057
9100
  return
9058
9101
  }
9059
- if (accel && (event.key === 'f' || event.key === 'F')) {
9102
+ if (accel && accelLetter(event, 'f')) {
9060
9103
  event.preventDefault()
9061
9104
  openFindPanel()
9062
9105
  return
9063
9106
  }
9064
- if (event.key === 'Escape') {
9107
+ if (event.key === 'Escape' || event.code === 'Escape') {
9065
9108
  setSelectedIds([])
9066
9109
  setEditing(null)
9067
9110
  setMenu(null)
@@ -9075,10 +9118,55 @@ window.__ModuleLoader__.load({
9075
9118
  setDocMenuPos(null)
9076
9119
  }
9077
9120
  }
9078
- window.addEventListener('keydown', onKey)
9079
- return () => window.removeEventListener('keydown', onKey)
9121
+ // 捕获阶段:抢在 DSH / 其他插件的冒泡监听之前(Ctrl+C 曾被截走导致"能删不能复制")。
9122
+ window.addEventListener('keydown', onKey, true)
9123
+ return () => window.removeEventListener('keydown', onKey, true)
9080
9124
  }, [selectedIds, editing, menu, placeTool, placeEdgeKind])
9081
9125
 
9126
+ // 复制 / 剪切的主通道:浏览器 `copy`/`cut` 事件(捕获)。
9127
+ // 比 keydown 更稳 —— 只要用户按了系统复制快捷键就会到这里,不依赖 event.key 字形。
9128
+ React.useEffect(() => {
9129
+ function ownsCopyCut(event) {
9130
+ if (!canvasOwnsKeyboard(event, rootRef.current, document.activeElement)) return false
9131
+ if (selectedIds.length === 0) return false
9132
+ // 画布有选中单元时,复制/剪切归画布(即使页面上还残留对话文字选区)。
9133
+ return true
9134
+ }
9135
+ function onCopy(event) {
9136
+ if (!ownsCopyCut(event)) return
9137
+ const clip = collectClipboard(docRef.current, selectedIds)
9138
+ if (clip === null) return
9139
+ event.preventDefault()
9140
+ event.stopPropagation()
9141
+ clipboard = clip
9142
+ setMenu(null)
9143
+ setSaveNote('已复制 ' + clip.nodes.length + ' 个节点' + (clip.edges.length > 0 ? ' / ' + clip.edges.length + ' 条连线' : ''))
9144
+ try {
9145
+ if (event.clipboardData !== null && event.clipboardData !== undefined) {
9146
+ event.clipboardData.setData('text/plain', 'DrawAI clipboard: ' + clip.nodes.length + ' node(s)')
9147
+ }
9148
+ } catch (error) {
9149
+ // 系统剪贴板写失败不影响画布内 clipboard
9150
+ }
9151
+ }
9152
+ function onCut(event) {
9153
+ if (!ownsCopyCut(event)) return
9154
+ const clip = collectClipboard(docRef.current, selectedIds)
9155
+ if (clip === null) return
9156
+ event.preventDefault()
9157
+ event.stopPropagation()
9158
+ clipboard = clip
9159
+ setSaveNote('已剪切 ' + clip.nodes.length + ' 个节点' + (clip.edges.length > 0 ? ' / ' + clip.edges.length + ' 条连线' : ''))
9160
+ deleteSelected()
9161
+ }
9162
+ document.addEventListener('copy', onCopy, true)
9163
+ document.addEventListener('cut', onCut, true)
9164
+ return () => {
9165
+ document.removeEventListener('copy', onCopy, true)
9166
+ document.removeEventListener('cut', onCut, true)
9167
+ }
9168
+ }, [selectedIds])
9169
+
9082
9170
  // 系统剪贴板粘贴:有图片就落成 image 节点;否则回落到画布内剪贴板(节点/边)。
9083
9171
  React.useEffect(() => {
9084
9172
  function onPaste(event) {
@@ -9097,8 +9185,8 @@ window.__ModuleLoader__.load({
9097
9185
  pasteClipboard()
9098
9186
  }
9099
9187
  }
9100
- window.addEventListener('paste', onPaste)
9101
- return () => window.removeEventListener('paste', onPaste)
9188
+ window.addEventListener('paste', onPaste, true)
9189
+ return () => window.removeEventListener('paste', onPaste, true)
9102
9190
  }, [editing, selectedIds, activeLayerId])
9103
9191
 
9104
9192
  React.useEffect(() => {
@@ -10970,6 +11058,9 @@ window.__ModuleLoader__.load({
10970
11058
  // ③ 在画布上按下 = "我要操作画布了":把残留在别处(比如对话里)的文字选区清掉。
10971
11059
  // 不清的话,画布的新元素会因为"外面还有选中文字"而复制不了(见 clearStaleTextSelection)。
10972
11060
  clearStaleTextSelection(target, event === null || event === undefined ? undefined : event.button)
11061
+ // ④ 同时把焦点收到面板根:否则 DSH 0.2 下对话 contenteditable 不放焦点,
11062
+ // canvasOwnsKeyboard 一直 false,Delete / Ctrl+C/V 全部没反应。
11063
+ claimCanvasKeyboard(rootRef.current, target)
10973
11064
  if (editing !== null && (closest === null || closest('.drawai-edit') === null)) commitEdit()
10974
11065
  if (docMenu === null) return
10975
11066
  if (closest !== null && closest('.drawai-menu') !== null) return
@@ -11325,7 +11416,14 @@ window.__ModuleLoader__.load({
11325
11416
 
11326
11417
  return React.createElement(
11327
11418
  'div',
11328
- { className: 'drawai-root', ref: rootRef, onPointerDownCapture: onRootPointerDown },
11419
+ {
11420
+ className: 'drawai-root',
11421
+ ref: rootRef,
11422
+ // 可聚焦但不进 Tab 序:点画布时 claimCanvasKeyboard 把焦点收到这里,
11423
+ // 快捷键才能通过 canvasOwnsKeyboard(见该函数注释)。
11424
+ tabIndex: -1,
11425
+ onPointerDownCapture: onRootPointerDown,
11426
+ },
11329
11427
  // 1) 工作栏(文件/编辑/视图/导出)—— 层级在标签页**之上**
11330
11428
  head,
11331
11429
  // 2) 标签页:由 CanvasTabs 构造、只在活动窗格里渲染(保证 DOM 里只有一份)
@@ -11755,6 +11853,7 @@ window.__ModuleLoader__.load({
11755
11853
  // 按键归属:谁在打字、这次按键算不算画布的 —— 纯函数,自测直接喂假 DOM 节点。
11756
11854
  isTextEntry: isTextEntry,
11757
11855
  canvasOwnsKeyboard: canvasOwnsKeyboard,
11856
+ claimCanvasKeyboard: claimCanvasKeyboard,
11758
11857
  // 「你有没有选中画布之外的文字」——决定 Ctrl+C/X/A 放不放手让浏览器去复制。
11759
11858
  hasTextSelectionOutside: hasTextSelectionOutside,
11760
11859
  drawaiPaneOf: drawaiPaneOf,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-drawai",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "description": "DSH 右侧栏的可编辑画布 + AI 语义绘图工具(diagram_apply / diagram_read),直接读写原生 .drawio 文件",
6
6
  "license": "MIT",