@dsh-plugin/dsh-image-annotate 0.0.0-stage → 0.0.1
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/LICENSE +21 -0
- package/README.en.md +203 -0
- package/README.md +202 -2
- package/cordis.patch.yml +8 -0
- package/lib/client.js +1965 -0
- package/lib/index.js +14 -0
- package/package.json +64 -4
package/lib/client.js
ADDED
|
@@ -0,0 +1,1965 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-image-annotate — 浏览器半区(client half)。
|
|
3
|
+
*
|
|
4
|
+
* 给 DSH Web 的图片预览(ImageLightbox,body 级 portal 的
|
|
5
|
+
* `div[role="dialog"][aria-modal="true"] > img`)加一支画笔:
|
|
6
|
+
*
|
|
7
|
+
* 1. 预览打开时,右下角出现「标注」按钮;
|
|
8
|
+
* 2. 点它进入标注模式:画笔 / 箭头 / 方框 / 圆圈 / 文字 / 擦除,
|
|
9
|
+
* 可选颜色与粗细,支持撤销、重做、清空;
|
|
10
|
+
* 3. 「加入输入框」把原图与标注合成一张 PNG,塞进当前会话的输入框
|
|
11
|
+
* (优先合成 paste 事件,失败退回拖放、剪贴板、下载三条路),
|
|
12
|
+
* 关掉预览,用户接着打字发送即可——模型看到的就是标注后的图。
|
|
13
|
+
*
|
|
14
|
+
* 设计取舍:
|
|
15
|
+
* - 不依赖任何 dsh 内部包/服务:整包就是一个 `window.__ModuleLoader__.load`
|
|
16
|
+
* 工厂,手写、无构建步骤,改动即被 client-modules 的 HMR 按 mtime 感知。
|
|
17
|
+
* - 不注册 slot、不接管原有 lightbox:只在预览 DOM 上叠加一层,
|
|
18
|
+
* 预览关闭即整体消失,宿主 React 重渲染也不会被我们改坏。
|
|
19
|
+
* - 所有笔迹都以「原图像素坐标」存储,因此叠加显示、导出、
|
|
20
|
+
* 窗口缩放三者永远一致。
|
|
21
|
+
*/
|
|
22
|
+
window.__ModuleLoader__.load({
|
|
23
|
+
id: "@dsh-plugin/dsh-image-annotate",
|
|
24
|
+
factory: (require) => {
|
|
25
|
+
'use strict';
|
|
26
|
+
|
|
27
|
+
var module = { exports: {} };
|
|
28
|
+
var exports = module.exports;
|
|
29
|
+
|
|
30
|
+
/* ==================================================================
|
|
31
|
+
* 常量与基础工具
|
|
32
|
+
* ================================================================== */
|
|
33
|
+
|
|
34
|
+
var PKG_ID = '@dsh-plugin/dsh-image-annotate';
|
|
35
|
+
var LOG_PREFIX = '[image-annotate]';
|
|
36
|
+
|
|
37
|
+
/** 调试开关:localStorage['@dsh-plugin/dsh-image-annotate.debug'] = '1' */
|
|
38
|
+
function debugEnabled() {
|
|
39
|
+
try {
|
|
40
|
+
return localStorage.getItem(PKG_ID + '.debug') === '1';
|
|
41
|
+
} catch (error) {
|
|
42
|
+
return false;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function log() {
|
|
47
|
+
if (!debugEnabled()) return;
|
|
48
|
+
var args = Array.prototype.slice.call(arguments);
|
|
49
|
+
args.unshift(LOG_PREFIX);
|
|
50
|
+
console.log.apply(console, args);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function warn() {
|
|
54
|
+
var args = Array.prototype.slice.call(arguments);
|
|
55
|
+
args.unshift(LOG_PREFIX);
|
|
56
|
+
console.warn.apply(console, args);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clamp(value, min, max) {
|
|
60
|
+
return Math.max(min, Math.min(max, value));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function stamp() {
|
|
64
|
+
var d = new Date();
|
|
65
|
+
function pad(n) {
|
|
66
|
+
return n < 10 ? '0' + n : String(n);
|
|
67
|
+
}
|
|
68
|
+
return (
|
|
69
|
+
String(d.getFullYear()) +
|
|
70
|
+
pad(d.getMonth() + 1) +
|
|
71
|
+
pad(d.getDate()) +
|
|
72
|
+
'-' +
|
|
73
|
+
pad(d.getHours()) +
|
|
74
|
+
pad(d.getMinutes()) +
|
|
75
|
+
pad(d.getSeconds())
|
|
76
|
+
);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function svgIcon(pathData) {
|
|
80
|
+
var NS = 'http://www.w3.org/2000/svg';
|
|
81
|
+
var svg = document.createElementNS(NS, 'svg');
|
|
82
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
83
|
+
svg.setAttribute('width', '16');
|
|
84
|
+
svg.setAttribute('height', '16');
|
|
85
|
+
svg.setAttribute('fill', 'none');
|
|
86
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
87
|
+
svg.setAttribute('stroke-width', '1.7');
|
|
88
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
89
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
90
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
91
|
+
var path = document.createElementNS(NS, 'path');
|
|
92
|
+
path.setAttribute('d', pathData);
|
|
93
|
+
svg.appendChild(path);
|
|
94
|
+
return svg;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function iconButton(iconPath, label, extraClass) {
|
|
98
|
+
var button = document.createElement('button');
|
|
99
|
+
button.type = 'button';
|
|
100
|
+
button.className = 'dsa-btn' + (extraClass ? ' ' + extraClass : '');
|
|
101
|
+
button.title = label;
|
|
102
|
+
button.setAttribute('aria-label', label);
|
|
103
|
+
button.appendChild(svgIcon(iconPath));
|
|
104
|
+
return button;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ==================================================================
|
|
108
|
+
* 文案(中英双语,跟随界面语言)
|
|
109
|
+
* ================================================================== */
|
|
110
|
+
|
|
111
|
+
var DICT = {
|
|
112
|
+
zh: {
|
|
113
|
+
annotate: '标注',
|
|
114
|
+
annotateTitle: '标注图片:圈画后把标注图发给 DSH',
|
|
115
|
+
select: '选择 / 移动 (V)',
|
|
116
|
+
pen: '画笔',
|
|
117
|
+
arrow: '箭头',
|
|
118
|
+
rect: '方框',
|
|
119
|
+
ellipse: '圆圈',
|
|
120
|
+
text: '文字',
|
|
121
|
+
eraser: '擦除',
|
|
122
|
+
undo: '撤销 (Ctrl+Z)',
|
|
123
|
+
redo: '重做 (Ctrl+Shift+Z)',
|
|
124
|
+
clear: '清空全部',
|
|
125
|
+
deleteSelected: '删除选中 (Delete)',
|
|
126
|
+
attach: '加入输入框',
|
|
127
|
+
attachTitle: '把标注后的图片放进输入框',
|
|
128
|
+
download: '下载标注图 PNG',
|
|
129
|
+
exit: '退出标注',
|
|
130
|
+
textPlaceholder: '打字后回车',
|
|
131
|
+
empty: '先画点什么再发送吧',
|
|
132
|
+
working: '正在生成标注图…',
|
|
133
|
+
attached: '已加入输入框,输入文字后发送即可',
|
|
134
|
+
uncertain: '已复制到剪贴板,按 Ctrl+V 贴进输入框',
|
|
135
|
+
downloaded: '标注图已下载',
|
|
136
|
+
exportFailed: '导出标注图失败',
|
|
137
|
+
noEditor: '没找到输入框:请先打开一个会话再标注',
|
|
138
|
+
selected: '已选中:拖动移动,双击文字改字,Delete 删除',
|
|
139
|
+
color_red: '红色',
|
|
140
|
+
color_amber: '橙色',
|
|
141
|
+
color_yellow: '黄色',
|
|
142
|
+
color_green: '绿色',
|
|
143
|
+
color_blue: '蓝色',
|
|
144
|
+
color_brand: '品牌蓝',
|
|
145
|
+
color_ink: '黑色',
|
|
146
|
+
color_paper: '白色'
|
|
147
|
+
},
|
|
148
|
+
en: {
|
|
149
|
+
annotate: 'Annotate',
|
|
150
|
+
annotateTitle: 'Annotate the image, then send the marked copy to DSH',
|
|
151
|
+
select: 'Select / move (V)',
|
|
152
|
+
pen: 'Pen',
|
|
153
|
+
arrow: 'Arrow',
|
|
154
|
+
rect: 'Box',
|
|
155
|
+
ellipse: 'Circle',
|
|
156
|
+
text: 'Text',
|
|
157
|
+
eraser: 'Erase',
|
|
158
|
+
undo: 'Undo (Ctrl+Z)',
|
|
159
|
+
redo: 'Redo (Ctrl+Shift+Z)',
|
|
160
|
+
clear: 'Clear all',
|
|
161
|
+
deleteSelected: 'Delete selection (Delete)',
|
|
162
|
+
attach: 'Add to composer',
|
|
163
|
+
attachTitle: 'Put the annotated image into the composer',
|
|
164
|
+
download: 'Download annotated PNG',
|
|
165
|
+
exit: 'Exit annotation',
|
|
166
|
+
textPlaceholder: 'Type, then Enter',
|
|
167
|
+
empty: 'Draw something first',
|
|
168
|
+
working: 'Rendering annotated image…',
|
|
169
|
+
attached: 'Added to the composer — type your message and send',
|
|
170
|
+
uncertain: 'Copied to clipboard — press Ctrl+V in the composer',
|
|
171
|
+
downloaded: 'Annotated PNG downloaded',
|
|
172
|
+
exportFailed: 'Failed to export the annotated image',
|
|
173
|
+
noEditor: 'No composer found — open a session first',
|
|
174
|
+
selected: 'Selected: drag to move, double-click text to edit, Delete to remove',
|
|
175
|
+
color_red: 'Red',
|
|
176
|
+
color_amber: 'Amber',
|
|
177
|
+
color_yellow: 'Yellow',
|
|
178
|
+
color_green: 'Green',
|
|
179
|
+
color_blue: 'Blue',
|
|
180
|
+
color_brand: 'Brand blue',
|
|
181
|
+
color_ink: 'Black',
|
|
182
|
+
color_paper: 'White'
|
|
183
|
+
}
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
function detectLocale(ctx) {
|
|
187
|
+
try {
|
|
188
|
+
var locale = ctx && typeof ctx.get === 'function' ? ctx.get('locale') : undefined;
|
|
189
|
+
var snapshot = locale && typeof locale.getLocale === 'function' ? locale.getLocale() : undefined;
|
|
190
|
+
var id = snapshot && (snapshot.id || snapshot.locale || snapshot.language);
|
|
191
|
+
if (typeof id === 'string' && id.length > 0) {
|
|
192
|
+
return id.toLowerCase().indexOf('zh') === 0 ? 'zh' : 'en';
|
|
193
|
+
}
|
|
194
|
+
} catch (error) {
|
|
195
|
+
/* 忽略:退回 navigator.language */
|
|
196
|
+
}
|
|
197
|
+
var nav = typeof navigator !== 'undefined' ? navigator.language || '' : '';
|
|
198
|
+
return nav.toLowerCase().indexOf('zh') === 0 ? 'zh' : 'en';
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function makeTranslator(ctx) {
|
|
202
|
+
var lang = detectLocale(ctx);
|
|
203
|
+
var table = DICT[lang] || DICT.en;
|
|
204
|
+
return function t(key) {
|
|
205
|
+
var value = table[key];
|
|
206
|
+
return value === undefined ? key : value;
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/* ==================================================================
|
|
211
|
+
* 样式(只注入一次;用 dsh 主题 token,取不到时用暗色兜底)
|
|
212
|
+
* ================================================================== */
|
|
213
|
+
|
|
214
|
+
var STYLE_TAG_ID = PKG_ID + '/styles.css';
|
|
215
|
+
|
|
216
|
+
function injectStyles() {
|
|
217
|
+
if (document.querySelector('style[data-plugin-css="' + STYLE_TAG_ID + '"]') !== null) return;
|
|
218
|
+
var style = document.createElement('style');
|
|
219
|
+
style.dataset.plugin = PKG_ID;
|
|
220
|
+
style.dataset.pluginCss = STYLE_TAG_ID;
|
|
221
|
+
style.textContent = [
|
|
222
|
+
/* 全部走 dsh 原生设计 token(theme 包里的 --dsw-*),
|
|
223
|
+
* 取不到时退回同样的暗色近似值,保证任何主题下都不跑偏。 */
|
|
224
|
+
'[data-dsa-root]{position:fixed;inset:0;pointer-events:none;z-index:2147482000;' +
|
|
225
|
+
'font-family:var(--dsw-font-family,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",' +
|
|
226
|
+
'"Hiragino Sans GB","Microsoft YaHei",sans-serif);font-size:14px;line-height:22px;' +
|
|
227
|
+
'color:var(--dsw-alias-label-primary,#0f1115);-webkit-font-smoothing:antialiased}',
|
|
228
|
+
'.dsa-canvas{position:fixed;pointer-events:none;touch-action:none;' +
|
|
229
|
+
'border-radius:var(--dsw-radius-lg,12px);box-shadow:0 0 0 .5px var(--dsw-alias-border-l2-darkmode-thin,#0000001a)}',
|
|
230
|
+
'.dsa-canvas[data-dsa-active="1"]{pointer-events:auto}',
|
|
231
|
+
|
|
232
|
+
/* 「标注」入口 = 预览右上角关闭按钮的同款圆形按钮(右下角) */
|
|
233
|
+
'.dsa-fab{position:fixed;right:20px;bottom:20px;pointer-events:auto;display:grid;place-items:center;' +
|
|
234
|
+
'width:36px;height:36px;padding:0;border:.5px solid var(--dsw-alias-border-l2-darkmode-thin,#0000001a);' +
|
|
235
|
+
'border-radius:999px;corner-shape:round;background:var(--dsw-specific-input-major,#fff);' +
|
|
236
|
+
'color:var(--dsw-alias-label-primary,#0f1115);cursor:pointer;box-shadow:var(--dsw-elevation-panel)}',
|
|
237
|
+
'.dsa-fab:hover{background:var(--dsw-alias-interactive-bg-hover-solid,#ebeef2)}',
|
|
238
|
+
'.dsa-fab:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ' +
|
|
239
|
+
'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:3px}',
|
|
240
|
+
|
|
241
|
+
/* 工具条 = 原生菜单浮层的材质与圆角 */
|
|
242
|
+
'.dsa-bar{position:fixed;left:50%;transform:translateX(-50%);bottom:20px;pointer-events:auto;display:flex;' +
|
|
243
|
+
'align-items:center;gap:2px;padding:4px;border-radius:var(--dsw-radius-lg,12px);' +
|
|
244
|
+
'background:var(--dsw-menu-surface-fill,var(--dsw-specific-input-major,#fff));' +
|
|
245
|
+
'backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));' +
|
|
246
|
+
'color:var(--dsw-alias-label-primary,#0f1115);box-shadow:var(--dsw-elevation-panel);' +
|
|
247
|
+
/* left 已经占了 50%:不给 width:max-content 的话,盒子只按「left 右侧剩余空间」
|
|
248
|
+
收缩,稍宽就换行(原生 Toast 也踩过这个坑) */
|
|
249
|
+
'width:max-content;max-width:min(96vw,940px);flex-wrap:wrap;justify-content:center}',
|
|
250
|
+
|
|
251
|
+
'.dsa-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;' +
|
|
252
|
+
'border:0;border-radius:var(--dsw-radius-md,8px);background:transparent;flex:none;' +
|
|
253
|
+
'color:var(--dsw-alias-label-secondary,#545557);cursor:pointer;' +
|
|
254
|
+
'transition:background .12s ease,color .12s ease}',
|
|
255
|
+
'.dsa-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#2631480f);' +
|
|
256
|
+
'color:var(--dsw-alias-label-primary,#0f1115)}',
|
|
257
|
+
'.dsa-btn[data-dsa-on="1"]{background:var(--dsw-alias-button-tool-bar-fill,#ebeef2);' +
|
|
258
|
+
'color:var(--dsw-alias-label-primary,#0f1115)}',
|
|
259
|
+
'.dsa-btn[data-dsa-on="1"]:hover{background:var(--dsw-alias-button-tool-bar-hover,#e1e5ee)}',
|
|
260
|
+
'.dsa-btn:disabled{opacity:.4;cursor:not-allowed}',
|
|
261
|
+
'.dsa-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ' +
|
|
262
|
+
'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:-2px}',
|
|
263
|
+
|
|
264
|
+
'.dsa-sep{width:1px;height:16px;margin:0 3px;flex:none;background:var(--dsw-alias-border-l3,#0000001f)}',
|
|
265
|
+
|
|
266
|
+
'.dsa-swatch{width:18px;height:18px;margin:0 2px;padding:0;flex:none;border:0;border-radius:999px;' +
|
|
267
|
+
'cursor:pointer;box-shadow:inset 0 0 0 1px #0000001f}',
|
|
268
|
+
'.dsa-swatch[data-dsa-on="1"]{outline:2px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:1px}',
|
|
269
|
+
|
|
270
|
+
'.dsa-width{width:30px;height:30px;padding:0;flex:none;border:0;border-radius:var(--dsw-radius-md,8px);' +
|
|
271
|
+
'background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}',
|
|
272
|
+
'.dsa-width:hover{background:var(--dsw-alias-interactive-bg-hover,#2631480f)}',
|
|
273
|
+
'.dsa-width span{display:block;border-radius:999px;background:var(--dsw-alias-label-secondary,#545557)}',
|
|
274
|
+
'.dsa-width[data-dsa-on="1"]{background:var(--dsw-alias-button-tool-bar-fill,#ebeef2)}',
|
|
275
|
+
'.dsa-width[data-dsa-on="1"] span{background:var(--dsw-alias-label-primary,#0f1115)}',
|
|
276
|
+
|
|
277
|
+
'.dsa-primary{display:inline-flex;align-items:center;gap:4px;height:30px;margin-left:3px;padding:0 12px;' +
|
|
278
|
+
'border:0;border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-button-primary-fill,#4176e6);' +
|
|
279
|
+
'color:var(--dsw-alias-label-primary-foreground,#fff);font-size:13px;line-height:20px;font-weight:500;' +
|
|
280
|
+
'cursor:pointer;white-space:nowrap}',
|
|
281
|
+
'.dsa-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#5686fe)}',
|
|
282
|
+
'.dsa-primary[data-dsa-busy="1"]{opacity:.6;cursor:progress}',
|
|
283
|
+
|
|
284
|
+
/* 提示条沿用原生 Toast 的面板:顶部居中、固定深色底 */
|
|
285
|
+
'.dsa-toast{position:fixed;top:40px;left:50%;transform:translateX(-50%);pointer-events:none;' +
|
|
286
|
+
'width:max-content;max-width:min(640px,calc(100vw - 48px));padding:12px 16px;text-align:center;' +
|
|
287
|
+
'border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-alias-toast-bg,#2b3244);' +
|
|
288
|
+
'color:var(--dsw-alias-toast-label,#fff);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3)}',
|
|
289
|
+
|
|
290
|
+
'.dsa-textinput{position:fixed;pointer-events:auto;min-width:160px;height:32px;padding:0 10px;' +
|
|
291
|
+
'border:1px solid var(--dsw-alias-state-business-primary,#4176e6);border-radius:var(--dsw-radius-md,8px);' +
|
|
292
|
+
'background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#0f1115);' +
|
|
293
|
+
'font-family:inherit;font-size:14px;outline:none;box-shadow:var(--dsw-elevation-soft)}',
|
|
294
|
+
|
|
295
|
+
'@media (prefers-reduced-motion:reduce){.dsa-btn,.dsa-fab{transition:none}}'
|
|
296
|
+
].join('\n');
|
|
297
|
+
document.head.appendChild(style);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/* ==================================================================
|
|
301
|
+
* 画笔定义(色板直接引用 dsh 原生色 token,随主题走)
|
|
302
|
+
* ================================================================== */
|
|
303
|
+
|
|
304
|
+
var PALETTE = [
|
|
305
|
+
{ id: 'red', token: '--dsw-static-red-500', fallback: '#e5484d' },
|
|
306
|
+
{ id: 'amber', token: '--dsw-static-amber-500', fallback: '#f59e0b' },
|
|
307
|
+
{ id: 'yellow', token: '--dsw-static-amber-400', fallback: '#f7ad31' },
|
|
308
|
+
{ id: 'green', token: '--dsw-static-green-500', fallback: '#22c55e' },
|
|
309
|
+
{ id: 'blue', token: '--dsw-static-blue-500', fallback: '#3b82f6' },
|
|
310
|
+
{ id: 'brand', token: '--dsw-static-deepseek-450', fallback: '#5686fe' },
|
|
311
|
+
{ id: 'ink', token: '--dsw-static-neutral-1000', fallback: '#000000' },
|
|
312
|
+
{ id: 'paper', token: '--dsw-static-neutral-00', fallback: '#ffffff' }
|
|
313
|
+
];
|
|
314
|
+
|
|
315
|
+
/** 主题 token → 具体颜色值(canvas 只能吃具体值)。 */
|
|
316
|
+
var colorCache = {};
|
|
317
|
+
|
|
318
|
+
function tokenColor(entry) {
|
|
319
|
+
if (Object.prototype.hasOwnProperty.call(colorCache, entry.token)) return colorCache[entry.token];
|
|
320
|
+
var value = '';
|
|
321
|
+
try {
|
|
322
|
+
value = (getComputedStyle(document.body).getPropertyValue(entry.token) || '').trim();
|
|
323
|
+
} catch (error) {
|
|
324
|
+
value = '';
|
|
325
|
+
}
|
|
326
|
+
if (value.length === 0) value = entry.fallback;
|
|
327
|
+
colorCache[entry.token] = value;
|
|
328
|
+
return value;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function paletteEntry(id) {
|
|
332
|
+
for (var i = 0; i < PALETTE.length; i++) {
|
|
333
|
+
if (PALETTE[i].id === id) return PALETTE[i];
|
|
334
|
+
}
|
|
335
|
+
return PALETTE[0];
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
var TOOLS = [
|
|
339
|
+
/* 「选择 / 移动」在最前:画完的每一笔都能再选回来拖位置 */
|
|
340
|
+
{ id: 'select', icon: 'M12 3.8v16.4M3.8 12h16.4M12 3.8L9.6 6.2M12 3.8l2.4 2.4M12 20.2l-2.4-2.4M12 20.2l2.4-2.4M3.8 12l2.4-2.4M3.8 12l2.4 2.4M20.2 12l-2.4-2.4M20.2 12l-2.4 2.4' },
|
|
341
|
+
{ id: 'pen', icon: 'M4.2 19.8l4.4-1L19.6 7.8a1.9 1.9 0 000-2.7l-1.6-1.6a1.9 1.9 0 00-2.7 0L4.3 14.4l-.9 4.4z' },
|
|
342
|
+
{ id: 'arrow', icon: 'M5 19L19 5M19 5h-6.4M19 5v6.4' },
|
|
343
|
+
{ id: 'rect', icon: 'M4.5 5.5h15v13h-15z' },
|
|
344
|
+
{ id: 'ellipse', icon: 'M12 5.6c4.1 0 7.4 2.9 7.4 6.4s-3.3 6.4-7.4 6.4S4.6 15.5 4.6 12 7.9 5.6 12 5.6z' },
|
|
345
|
+
{ id: 'text', icon: 'M5 6.5h14M12 6.5V19M9 19h6' },
|
|
346
|
+
{ id: 'eraser', icon: 'M4.6 15.4l7-7 6.4 6.4-4 4H9.2l-4.6-3.4zM8.4 19.8h12' }
|
|
347
|
+
];
|
|
348
|
+
|
|
349
|
+
var ICONS = {
|
|
350
|
+
undo: 'M9 15L4.5 10.5 9 6M4.5 10.5h9.4a5.6 5.6 0 010 11.2H9',
|
|
351
|
+
redo: 'M15 15l4.5-4.5L15 6M19.5 10.5H10.1a5.6 5.6 0 000 11.2H15',
|
|
352
|
+
trash: 'M5 7.5h14M9.5 7.5V5h5v2.5M7 7.5l.8 12h8.4l.8-12',
|
|
353
|
+
download: 'M12 4v11m0 0l-4-4m4 4l4-4M5 19.5h14',
|
|
354
|
+
close: 'M6 6l12 12M18 6L6 18',
|
|
355
|
+
check: 'M20 6.6L9.5 17.4 4 11.9',
|
|
356
|
+
pen: 'M4.2 19.8l4.4-1L19.6 7.8a1.9 1.9 0 000-2.7l-1.6-1.6a1.9 1.9 0 00-2.7 0L4.3 14.4l-.9 4.4z'
|
|
357
|
+
};
|
|
358
|
+
|
|
359
|
+
/* ==================================================================
|
|
360
|
+
* 笔迹绘制:坐标全部是「原图像素」,因此缩放/导出共用同一套代码
|
|
361
|
+
* ================================================================== */
|
|
362
|
+
|
|
363
|
+
function strokeBounds(points) {
|
|
364
|
+
var minX = Infinity;
|
|
365
|
+
var minY = Infinity;
|
|
366
|
+
var maxX = -Infinity;
|
|
367
|
+
var maxY = -Infinity;
|
|
368
|
+
for (var i = 0; i < points.length; i++) {
|
|
369
|
+
var point = points[i];
|
|
370
|
+
if (point.x < minX) minX = point.x;
|
|
371
|
+
if (point.y < minY) minY = point.y;
|
|
372
|
+
if (point.x > maxX) maxX = point.x;
|
|
373
|
+
if (point.y > maxY) maxY = point.y;
|
|
374
|
+
}
|
|
375
|
+
return { minX: minX, minY: minY, maxX: maxX, maxY: maxY, width: maxX - minX, height: maxY - minY };
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
function paintStroke(g, stroke) {
|
|
379
|
+
var points = stroke.points || [];
|
|
380
|
+
g.save();
|
|
381
|
+
g.lineCap = 'round';
|
|
382
|
+
g.lineJoin = 'round';
|
|
383
|
+
g.strokeStyle = stroke.color;
|
|
384
|
+
g.fillStyle = stroke.color;
|
|
385
|
+
g.lineWidth = Math.max(0.6, stroke.width || 1);
|
|
386
|
+
|
|
387
|
+
if (stroke.tool === 'pen') {
|
|
388
|
+
if (points.length === 0) {
|
|
389
|
+
/* 无点可画 */
|
|
390
|
+
} else if (points.length === 1) {
|
|
391
|
+
g.beginPath();
|
|
392
|
+
g.arc(points[0].x, points[0].y, g.lineWidth / 2, 0, Math.PI * 2);
|
|
393
|
+
g.fill();
|
|
394
|
+
} else {
|
|
395
|
+
g.beginPath();
|
|
396
|
+
g.moveTo(points[0].x, points[0].y);
|
|
397
|
+
for (var i = 1; i < points.length; i++) g.lineTo(points[i].x, points[i].y);
|
|
398
|
+
g.stroke();
|
|
399
|
+
}
|
|
400
|
+
} else if (stroke.tool === 'arrow') {
|
|
401
|
+
var from = points[0];
|
|
402
|
+
var to = points[points.length - 1];
|
|
403
|
+
if (from && to) {
|
|
404
|
+
var head = Math.max(g.lineWidth * 4.2, 9);
|
|
405
|
+
var angle = Math.atan2(to.y - from.y, to.x - from.x);
|
|
406
|
+
g.beginPath();
|
|
407
|
+
g.moveTo(from.x, from.y);
|
|
408
|
+
g.lineTo(to.x, to.y);
|
|
409
|
+
g.stroke();
|
|
410
|
+
g.beginPath();
|
|
411
|
+
g.moveTo(to.x, to.y);
|
|
412
|
+
g.lineTo(to.x - head * Math.cos(angle - 0.42), to.y - head * Math.sin(angle - 0.42));
|
|
413
|
+
g.moveTo(to.x, to.y);
|
|
414
|
+
g.lineTo(to.x - head * Math.cos(angle + 0.42), to.y - head * Math.sin(angle + 0.42));
|
|
415
|
+
g.stroke();
|
|
416
|
+
}
|
|
417
|
+
} else if (stroke.tool === 'rect') {
|
|
418
|
+
var rectBox = strokeBounds(points);
|
|
419
|
+
if (rectBox.width > 0 || rectBox.height > 0) {
|
|
420
|
+
g.strokeRect(rectBox.minX, rectBox.minY, rectBox.width, rectBox.height);
|
|
421
|
+
}
|
|
422
|
+
} else if (stroke.tool === 'ellipse') {
|
|
423
|
+
/* 用整条轨迹的外接框,而不是「起点→终点」:这样手绘一圈
|
|
424
|
+
*(回到起点)与对角拖拽画椭圆都符合直觉。 */
|
|
425
|
+
var ellipseBox = strokeBounds(points);
|
|
426
|
+
if (ellipseBox.width > 0 || ellipseBox.height > 0) {
|
|
427
|
+
g.beginPath();
|
|
428
|
+
g.ellipse(
|
|
429
|
+
(ellipseBox.minX + ellipseBox.maxX) / 2,
|
|
430
|
+
(ellipseBox.minY + ellipseBox.maxY) / 2,
|
|
431
|
+
ellipseBox.width / 2,
|
|
432
|
+
ellipseBox.height / 2,
|
|
433
|
+
0,
|
|
434
|
+
0,
|
|
435
|
+
Math.PI * 2
|
|
436
|
+
);
|
|
437
|
+
g.stroke();
|
|
438
|
+
}
|
|
439
|
+
} else if (stroke.tool === 'text') {
|
|
440
|
+
var size = stroke.fontSize || g.lineWidth * 7;
|
|
441
|
+
g.font = textFont(size);
|
|
442
|
+
g.textBaseline = 'top';
|
|
443
|
+
g.lineWidth = Math.max(1, size * 0.16);
|
|
444
|
+
g.strokeStyle = 'rgba(255,255,255,.92)';
|
|
445
|
+
g.strokeText(stroke.text || '', stroke.x, stroke.y);
|
|
446
|
+
g.fillStyle = stroke.color;
|
|
447
|
+
g.fillText(stroke.text || '', stroke.x, stroke.y);
|
|
448
|
+
}
|
|
449
|
+
g.restore();
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
function distanceToSegment(px, py, ax, ay, bx, by) {
|
|
453
|
+
var dx = bx - ax;
|
|
454
|
+
var dy = by - ay;
|
|
455
|
+
var lengthSq = dx * dx + dy * dy;
|
|
456
|
+
var t = lengthSq === 0 ? 0 : clamp(((px - ax) * dx + (py - ay) * dy) / lengthSq, 0, 1);
|
|
457
|
+
var x = ax + t * dx;
|
|
458
|
+
var y = ay + t * dy;
|
|
459
|
+
return Math.sqrt((px - x) * (px - x) + (py - y) * (py - y));
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
function cloneStrokes(strokes) {
|
|
463
|
+
return strokes.map(cloneStroke);
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
function cloneStroke(stroke) {
|
|
467
|
+
var copy = {};
|
|
468
|
+
for (var key in stroke) if (Object.prototype.hasOwnProperty.call(stroke, key)) copy[key] = stroke[key];
|
|
469
|
+
if (stroke.points) {
|
|
470
|
+
copy.points = stroke.points.map(function (point) {
|
|
471
|
+
return { x: point.x, y: point.y };
|
|
472
|
+
});
|
|
473
|
+
}
|
|
474
|
+
return copy;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/* ==================================================================
|
|
478
|
+
* 选中 / 移动 / 缩放:所有笔迹都以外接框为准(坐标仍是原图像素)
|
|
479
|
+
* ================================================================== */
|
|
480
|
+
|
|
481
|
+
function textFont(size) {
|
|
482
|
+
return (
|
|
483
|
+
'600 ' +
|
|
484
|
+
size +
|
|
485
|
+
'px -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif'
|
|
486
|
+
);
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
var measureCanvas = null;
|
|
490
|
+
|
|
491
|
+
/** 量一行文字的宽度(原图像素),用于选中框与擦除命中。 */
|
|
492
|
+
function measureTextWidth(text, fontSize) {
|
|
493
|
+
try {
|
|
494
|
+
if (measureCanvas === null) measureCanvas = document.createElement('canvas');
|
|
495
|
+
var g = measureCanvas.getContext('2d');
|
|
496
|
+
g.font = textFont(fontSize);
|
|
497
|
+
return g.measureText(String(text)).width;
|
|
498
|
+
} catch (error) {
|
|
499
|
+
return fontSize * 0.62 * String(text).length;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function textWidthOf(stroke) {
|
|
504
|
+
var size = stroke.fontSize || 20;
|
|
505
|
+
if (typeof stroke.textWidth === 'number' && stroke.textWidth > 0) return stroke.textWidth;
|
|
506
|
+
return measureTextWidth(stroke.text || '', size);
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
/** 一笔的外接框(原图像素坐标);无点可框时返回 null。 */
|
|
510
|
+
function strokeBox(stroke) {
|
|
511
|
+
if (stroke.tool === 'text') {
|
|
512
|
+
var size = stroke.fontSize || 20;
|
|
513
|
+
return { x: stroke.x, y: stroke.y, w: textWidthOf(stroke), h: size * 1.25 };
|
|
514
|
+
}
|
|
515
|
+
var points = stroke.points || [];
|
|
516
|
+
if (points.length === 0) return null;
|
|
517
|
+
var bounds = strokeBounds(points);
|
|
518
|
+
var pad = (stroke.width || 2) / 2;
|
|
519
|
+
return { x: bounds.minX - pad, y: bounds.minY - pad, w: bounds.width + pad * 2, h: bounds.height + pad * 2 };
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
function translateStroke(stroke, dx, dy) {
|
|
523
|
+
var copy = cloneStroke(stroke);
|
|
524
|
+
if (copy.points) {
|
|
525
|
+
copy.points = copy.points.map(function (point) {
|
|
526
|
+
return { x: point.x + dx, y: point.y + dy };
|
|
527
|
+
});
|
|
528
|
+
}
|
|
529
|
+
if (copy.tool === 'text') {
|
|
530
|
+
copy.x = stroke.x + dx;
|
|
531
|
+
copy.y = stroke.y + dy;
|
|
532
|
+
}
|
|
533
|
+
return copy;
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** 绕 (originX, originY) 等比缩放一笔(文字同时缩字号)。 */
|
|
537
|
+
function scaleStroke(stroke, factor, originX, originY) {
|
|
538
|
+
var copy = cloneStroke(stroke);
|
|
539
|
+
if (copy.points) {
|
|
540
|
+
copy.points = copy.points.map(function (point) {
|
|
541
|
+
return { x: originX + (point.x - originX) * factor, y: originY + (point.y - originY) * factor };
|
|
542
|
+
});
|
|
543
|
+
}
|
|
544
|
+
if (copy.tool === 'text') {
|
|
545
|
+
copy.x = originX + (stroke.x - originX) * factor;
|
|
546
|
+
copy.y = originY + (stroke.y - originY) * factor;
|
|
547
|
+
copy.fontSize = (stroke.fontSize || 20) * factor;
|
|
548
|
+
copy.textWidth = textWidthOf(stroke) * factor;
|
|
549
|
+
}
|
|
550
|
+
copy.width = (stroke.width || 2) * factor;
|
|
551
|
+
return copy;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/* ==================================================================
|
|
555
|
+
* 取原图像素:优先 fetch(blob) → ImageBitmap,退回直接绘制 <img>
|
|
556
|
+
* ================================================================== */
|
|
557
|
+
|
|
558
|
+
function loadImageElement(url) {
|
|
559
|
+
return new Promise(function (resolve, reject) {
|
|
560
|
+
var image = new Image();
|
|
561
|
+
image.onload = function () {
|
|
562
|
+
resolve(image);
|
|
563
|
+
};
|
|
564
|
+
image.onerror = function () {
|
|
565
|
+
reject(new Error('image load failed'));
|
|
566
|
+
};
|
|
567
|
+
image.src = url;
|
|
568
|
+
});
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* 取得一个可绘制、且不会污染 canvas 的图源。
|
|
573
|
+
*
|
|
574
|
+
* lightbox 的 src 可能是 blob:/data:/同源 http(s) 或 dsh 自定义协议;
|
|
575
|
+
* 直接 drawImage(<img>) 在跨源时会污染画布导致 toBlob 抛 SecurityError,
|
|
576
|
+
* 所以先尝试 fetch 成 blob 再解码(fetch 走 CORS,失败可捕获)。
|
|
577
|
+
*/
|
|
578
|
+
function loadSource(img) {
|
|
579
|
+
var src = img.currentSrc || img.src;
|
|
580
|
+
var cleanup = [];
|
|
581
|
+
return fetch(src, { credentials: 'include', cache: 'force-cache' })
|
|
582
|
+
.then(function (response) {
|
|
583
|
+
if (!response.ok) throw new Error('HTTP ' + response.status);
|
|
584
|
+
return response.blob();
|
|
585
|
+
})
|
|
586
|
+
.then(function (blob) {
|
|
587
|
+
if (blob.size === 0) throw new Error('empty blob');
|
|
588
|
+
if (typeof createImageBitmap === 'function') {
|
|
589
|
+
return createImageBitmap(blob, { imageOrientation: 'from-image' }).catch(function () {
|
|
590
|
+
var url = URL.createObjectURL(blob);
|
|
591
|
+
cleanup.push(url);
|
|
592
|
+
return loadImageElement(url);
|
|
593
|
+
});
|
|
594
|
+
}
|
|
595
|
+
var objectUrl = URL.createObjectURL(blob);
|
|
596
|
+
cleanup.push(objectUrl);
|
|
597
|
+
return loadImageElement(objectUrl);
|
|
598
|
+
})
|
|
599
|
+
.catch(function (error) {
|
|
600
|
+
log('fetch source failed, falling back to <img>', error);
|
|
601
|
+
return img;
|
|
602
|
+
})
|
|
603
|
+
.then(function (source) {
|
|
604
|
+
return {
|
|
605
|
+
source: source,
|
|
606
|
+
release: function () {
|
|
607
|
+
cleanup.forEach(function (url) {
|
|
608
|
+
try {
|
|
609
|
+
URL.revokeObjectURL(url);
|
|
610
|
+
} catch (ignored) {
|
|
611
|
+
/* 忽略 */
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
});
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
/* ==================================================================
|
|
620
|
+
* 输入框投递:paste → drop → 剪贴板 → 下载
|
|
621
|
+
* ================================================================== */
|
|
622
|
+
|
|
623
|
+
function composerEditor() {
|
|
624
|
+
/* 输入框就是 Lexical 的 root:`[data-composer-input]` 与
|
|
625
|
+
* `[data-lexical-editor="true"]` 是同一个 div(ui-conversation 在
|
|
626
|
+
* setRootElement 之后由 Lexical 打上后者)。优先精确命中前者。 */
|
|
627
|
+
var nodes = document.querySelectorAll('[data-composer-input], [data-lexical-editor="true"], [contenteditable="true"]');
|
|
628
|
+
var best = null;
|
|
629
|
+
var bestScore = -1;
|
|
630
|
+
for (var i = 0; i < nodes.length; i++) {
|
|
631
|
+
var node = nodes[i];
|
|
632
|
+
if (!node.isConnected) continue;
|
|
633
|
+
var rect = node.getBoundingClientRect();
|
|
634
|
+
if (rect.width < 40 || rect.height < 8) continue;
|
|
635
|
+
var score = 0;
|
|
636
|
+
if (node.hasAttribute('data-composer-input')) score += 6;
|
|
637
|
+
if (node.closest('[data-conversation-session]') !== null) score += 2;
|
|
638
|
+
if (node.getAttribute('data-lexical-editor') === 'true') score += 2;
|
|
639
|
+
if (node.getAttribute('role') === 'textbox') score += 1;
|
|
640
|
+
if (score >= bestScore) {
|
|
641
|
+
bestScore = score;
|
|
642
|
+
best = node;
|
|
643
|
+
}
|
|
644
|
+
}
|
|
645
|
+
return best;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
function countBlobImages() {
|
|
649
|
+
var count = 0;
|
|
650
|
+
var images = document.images;
|
|
651
|
+
for (var i = 0; i < images.length; i++) {
|
|
652
|
+
var url = String(images[i].currentSrc || images[i].src || '');
|
|
653
|
+
if (url.indexOf('blob:') === 0) count++;
|
|
654
|
+
}
|
|
655
|
+
return count;
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function waitFor(predicate, timeoutMs) {
|
|
659
|
+
return new Promise(function (resolve) {
|
|
660
|
+
var started = Date.now();
|
|
661
|
+
(function poll() {
|
|
662
|
+
var value;
|
|
663
|
+
try {
|
|
664
|
+
value = predicate();
|
|
665
|
+
} catch (error) {
|
|
666
|
+
value = false;
|
|
667
|
+
}
|
|
668
|
+
if (value) {
|
|
669
|
+
resolve(true);
|
|
670
|
+
return;
|
|
671
|
+
}
|
|
672
|
+
if (Date.now() - started >= timeoutMs) {
|
|
673
|
+
resolve(false);
|
|
674
|
+
return;
|
|
675
|
+
}
|
|
676
|
+
setTimeout(poll, 120);
|
|
677
|
+
})();
|
|
678
|
+
});
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
/**
|
|
682
|
+
* 造一个「带图片文件的 paste 事件」。
|
|
683
|
+
*
|
|
684
|
+
* composer 的入口读取 `event.clipboardData.items`(kind === 'file'),
|
|
685
|
+
* 因此先试真正的 ClipboardEvent;某些实现会静默丢弃 clipboardData
|
|
686
|
+
* init 成员,那就退回 defineProperty 挂一个同形状的假对象。
|
|
687
|
+
* 注意:Lexical 会给处理过的事件打 `_lexicalHandled`,所以每次派发
|
|
688
|
+
* 都必须新建事件,不能复用。
|
|
689
|
+
*/
|
|
690
|
+
function makePasteEvent(file) {
|
|
691
|
+
var dataTransfer = new DataTransfer();
|
|
692
|
+
dataTransfer.items.add(file);
|
|
693
|
+
var event = null;
|
|
694
|
+
try {
|
|
695
|
+
event = new ClipboardEvent('paste', {
|
|
696
|
+
clipboardData: dataTransfer,
|
|
697
|
+
bubbles: true,
|
|
698
|
+
cancelable: true,
|
|
699
|
+
composed: true
|
|
700
|
+
});
|
|
701
|
+
if (!event.clipboardData || event.clipboardData.items.length === 0) event = null;
|
|
702
|
+
} catch (error) {
|
|
703
|
+
event = null;
|
|
704
|
+
}
|
|
705
|
+
if (event === null) {
|
|
706
|
+
event = new Event('paste', { bubbles: true, cancelable: true, composed: true });
|
|
707
|
+
try {
|
|
708
|
+
Object.defineProperty(event, 'clipboardData', {
|
|
709
|
+
value: {
|
|
710
|
+
items: [
|
|
711
|
+
{
|
|
712
|
+
kind: 'file',
|
|
713
|
+
type: file.type,
|
|
714
|
+
getAsFile: function () {
|
|
715
|
+
return file;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
],
|
|
719
|
+
files: [file],
|
|
720
|
+
types: ['Files'],
|
|
721
|
+
getData: function () {
|
|
722
|
+
return '';
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
});
|
|
726
|
+
} catch (ignored) {
|
|
727
|
+
/* 忽略:真走到这里只能靠下载兜底 */
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
return event;
|
|
731
|
+
}
|
|
732
|
+
|
|
733
|
+
/** 用合成 paste 事件把文件交给 composer 的既有图片入口。返回是否命中编辑器。 */
|
|
734
|
+
function attachViaPaste(file) {
|
|
735
|
+
var editor = composerEditor();
|
|
736
|
+
if (editor === null) return false;
|
|
737
|
+
try {
|
|
738
|
+
editor.focus({ preventScroll: true });
|
|
739
|
+
} catch (error) {
|
|
740
|
+
try {
|
|
741
|
+
editor.focus();
|
|
742
|
+
} catch (ignored) {
|
|
743
|
+
/* 忽略 */
|
|
744
|
+
}
|
|
745
|
+
}
|
|
746
|
+
var event = makePasteEvent(file);
|
|
747
|
+
editor.dispatchEvent(event);
|
|
748
|
+
log('paste dispatched; defaultPrevented =', event.defaultPrevented);
|
|
749
|
+
return true;
|
|
750
|
+
}
|
|
751
|
+
|
|
752
|
+
/** 退回拖放路径:attachment 的 document 级监听接受 drop。 */
|
|
753
|
+
function attachViaDrop(file) {
|
|
754
|
+
var editor = composerEditor();
|
|
755
|
+
var host = null;
|
|
756
|
+
if (editor !== null) {
|
|
757
|
+
host = editor.closest('form') || editor.parentElement || editor;
|
|
758
|
+
}
|
|
759
|
+
if (host === null) host = document;
|
|
760
|
+
var dataTransfer = new DataTransfer();
|
|
761
|
+
dataTransfer.items.add(file);
|
|
762
|
+
var rect = (editor || host).getBoundingClientRect();
|
|
763
|
+
var clientX = rect.left + Math.max(10, Math.min(rect.width / 2, 60));
|
|
764
|
+
var clientY = rect.top + Math.max(8, Math.min(rect.height / 2, 20));
|
|
765
|
+
var types = ['dragenter', 'dragover', 'drop'];
|
|
766
|
+
for (var i = 0; i < types.length; i++) {
|
|
767
|
+
var event = new DragEvent(types[i], {
|
|
768
|
+
dataTransfer: dataTransfer,
|
|
769
|
+
bubbles: true,
|
|
770
|
+
cancelable: true,
|
|
771
|
+
composed: true,
|
|
772
|
+
clientX: clientX,
|
|
773
|
+
clientY: clientY
|
|
774
|
+
});
|
|
775
|
+
host.dispatchEvent(event);
|
|
776
|
+
}
|
|
777
|
+
log('drop dispatched');
|
|
778
|
+
return true;
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
function copyBlobToClipboard(blob) {
|
|
782
|
+
return new Promise(function (resolve) {
|
|
783
|
+
try {
|
|
784
|
+
if (!navigator.clipboard || typeof window.ClipboardItem !== 'function') {
|
|
785
|
+
resolve(false);
|
|
786
|
+
return;
|
|
787
|
+
}
|
|
788
|
+
navigator.clipboard
|
|
789
|
+
.write([new window.ClipboardItem({ 'image/png': blob })])
|
|
790
|
+
.then(function () {
|
|
791
|
+
resolve(true);
|
|
792
|
+
})
|
|
793
|
+
.catch(function (error) {
|
|
794
|
+
log('clipboard write failed', error);
|
|
795
|
+
resolve(false);
|
|
796
|
+
});
|
|
797
|
+
} catch (error) {
|
|
798
|
+
log('clipboard unavailable', error);
|
|
799
|
+
resolve(false);
|
|
800
|
+
}
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
function downloadBlob(blob, filename) {
|
|
805
|
+
try {
|
|
806
|
+
var url = URL.createObjectURL(blob);
|
|
807
|
+
var link = document.createElement('a');
|
|
808
|
+
link.href = url;
|
|
809
|
+
link.download = filename;
|
|
810
|
+
link.style.display = 'none';
|
|
811
|
+
document.body.appendChild(link);
|
|
812
|
+
link.click();
|
|
813
|
+
link.remove();
|
|
814
|
+
setTimeout(function () {
|
|
815
|
+
try {
|
|
816
|
+
URL.revokeObjectURL(url);
|
|
817
|
+
} catch (ignored) {
|
|
818
|
+
/* 忽略 */
|
|
819
|
+
}
|
|
820
|
+
}, 5000);
|
|
821
|
+
} catch (error) {
|
|
822
|
+
warn('download failed', error);
|
|
823
|
+
}
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
/* ==================================================================
|
|
827
|
+
* 标注器:一个 lightbox = 一个标注器
|
|
828
|
+
* ================================================================== */
|
|
829
|
+
|
|
830
|
+
function createAnnotator(dialog, img, t) {
|
|
831
|
+
var root = document.createElement('div');
|
|
832
|
+
root.setAttribute('data-dsa-root', '');
|
|
833
|
+
dialog.appendChild(root);
|
|
834
|
+
|
|
835
|
+
var canvas = document.createElement('canvas');
|
|
836
|
+
canvas.className = 'dsa-canvas';
|
|
837
|
+
root.appendChild(canvas);
|
|
838
|
+
|
|
839
|
+
var fab = document.createElement('button');
|
|
840
|
+
fab.type = 'button';
|
|
841
|
+
fab.className = 'dsa-fab';
|
|
842
|
+
fab.title = t('annotateTitle');
|
|
843
|
+
fab.setAttribute('aria-label', t('annotateTitle'));
|
|
844
|
+
fab.appendChild(svgIcon(ICONS.pen));
|
|
845
|
+
root.appendChild(fab);
|
|
846
|
+
|
|
847
|
+
var bar = document.createElement('div');
|
|
848
|
+
bar.className = 'dsa-bar';
|
|
849
|
+
bar.style.display = 'none';
|
|
850
|
+
root.appendChild(bar);
|
|
851
|
+
|
|
852
|
+
var toastEl = document.createElement('div');
|
|
853
|
+
toastEl.className = 'dsa-toast';
|
|
854
|
+
toastEl.style.display = 'none';
|
|
855
|
+
root.appendChild(toastEl);
|
|
856
|
+
|
|
857
|
+
var state = {
|
|
858
|
+
active: false,
|
|
859
|
+
tool: 'pen',
|
|
860
|
+
color: PALETTE[0].id,
|
|
861
|
+
widthIndex: 1,
|
|
862
|
+
strokes: [],
|
|
863
|
+
draft: null,
|
|
864
|
+
rect: null,
|
|
865
|
+
natural: { w: img.naturalWidth || 0, h: img.naturalHeight || 0 },
|
|
866
|
+
busy: false,
|
|
867
|
+
drawing: false,
|
|
868
|
+
textInput: null,
|
|
869
|
+
selected: null,
|
|
870
|
+
hovered: null,
|
|
871
|
+
drag: null,
|
|
872
|
+
past: [],
|
|
873
|
+
future: [],
|
|
874
|
+
rafId: 0,
|
|
875
|
+
timer: 0
|
|
876
|
+
};
|
|
877
|
+
|
|
878
|
+
/* ------------------------------ 尺寸与重绘 ------------------------------ */
|
|
879
|
+
|
|
880
|
+
function currentWidth() {
|
|
881
|
+
var base = state.natural.w > 0 ? clamp(state.natural.w * 0.0035, 1.6, 9) : 2.4;
|
|
882
|
+
if (state.widthIndex === 0) return base * 0.6;
|
|
883
|
+
if (state.widthIndex === 2) return base * 1.9;
|
|
884
|
+
return base;
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
function syncLayout() {
|
|
888
|
+
var rect = img.getBoundingClientRect();
|
|
889
|
+
if (rect.width < 4 || rect.height < 4) return;
|
|
890
|
+
/* 原图尺寸可能比预览晚到(点开瞬间图还在解码),一旦可用就纠正,
|
|
891
|
+
* 否则导出会退化成「显示尺寸」。 */
|
|
892
|
+
if (img.naturalWidth >= 2 && img.naturalHeight >= 2) {
|
|
893
|
+
if (state.natural.w !== img.naturalWidth || state.natural.h !== img.naturalHeight) {
|
|
894
|
+
state.natural = { w: img.naturalWidth, h: img.naturalHeight };
|
|
895
|
+
}
|
|
896
|
+
} else if (state.natural.w < 2 || state.natural.h < 2) {
|
|
897
|
+
state.natural = { w: Math.round(rect.width), h: Math.round(rect.height) };
|
|
898
|
+
}
|
|
899
|
+
state.rect = rect;
|
|
900
|
+
var dpr = window.devicePixelRatio || 1;
|
|
901
|
+
var width = Math.max(1, Math.round(rect.width * dpr));
|
|
902
|
+
var height = Math.max(1, Math.round(rect.height * dpr));
|
|
903
|
+
if (canvas.width !== width || canvas.height !== height) {
|
|
904
|
+
canvas.width = width;
|
|
905
|
+
canvas.height = height;
|
|
906
|
+
}
|
|
907
|
+
canvas.style.left = rect.left + 'px';
|
|
908
|
+
canvas.style.top = rect.top + 'px';
|
|
909
|
+
canvas.style.width = rect.width + 'px';
|
|
910
|
+
canvas.style.height = rect.height + 'px';
|
|
911
|
+
draw();
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
function draw() {
|
|
915
|
+
var g = canvas.getContext('2d');
|
|
916
|
+
g.setTransform(1, 0, 0, 1, 0, 0);
|
|
917
|
+
g.clearRect(0, 0, canvas.width, canvas.height);
|
|
918
|
+
if (canvas.width < 2 || state.natural.w < 2) return;
|
|
919
|
+
var scale = canvas.width / state.natural.w;
|
|
920
|
+
g.setTransform(scale, 0, 0, scale, 0, 0);
|
|
921
|
+
for (var i = 0; i < state.strokes.length; i++) paintStroke(g, state.strokes[i]);
|
|
922
|
+
if (state.draft !== null) paintStroke(g, state.draft);
|
|
923
|
+
paintChrome(g, scale);
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
/** 选中框 / 缩放手柄 / 悬停高亮:都画在同一层,缩放天然一致。 */
|
|
927
|
+
function paintChrome(g, scale) {
|
|
928
|
+
var accent = tokenColor({ token: '--dsw-alias-state-business-primary', fallback: '#4176e6' });
|
|
929
|
+
var stroke = state.selected;
|
|
930
|
+
if (stroke !== null && state.strokes.indexOf(stroke) >= 0) {
|
|
931
|
+
var box = strokeBox(stroke);
|
|
932
|
+
if (box !== null) {
|
|
933
|
+
g.save();
|
|
934
|
+
g.strokeStyle = accent;
|
|
935
|
+
g.lineWidth = 1.4 / scale;
|
|
936
|
+
g.setLineDash([6 / scale, 4 / scale]);
|
|
937
|
+
g.strokeRect(box.x, box.y, box.w, box.h);
|
|
938
|
+
g.setLineDash([]);
|
|
939
|
+
var size = 8 / scale;
|
|
940
|
+
var handles = handlePoints(box);
|
|
941
|
+
for (var i = 0; i < handles.length; i++) {
|
|
942
|
+
g.beginPath();
|
|
943
|
+
g.rect(handles[i].x - size / 2, handles[i].y - size / 2, size, size);
|
|
944
|
+
g.fillStyle = '#ffffff';
|
|
945
|
+
g.fill();
|
|
946
|
+
g.lineWidth = 1.4 / scale;
|
|
947
|
+
g.strokeStyle = accent;
|
|
948
|
+
g.stroke();
|
|
949
|
+
}
|
|
950
|
+
g.restore();
|
|
951
|
+
}
|
|
952
|
+
} else if (state.hovered !== null && state.tool === 'select') {
|
|
953
|
+
var hoverBox = strokeBox(state.hovered);
|
|
954
|
+
if (hoverBox !== null) {
|
|
955
|
+
g.save();
|
|
956
|
+
g.strokeStyle = accent;
|
|
957
|
+
g.globalAlpha = 0.55;
|
|
958
|
+
g.lineWidth = 1.4 / scale;
|
|
959
|
+
g.strokeRect(hoverBox.x - 2 / scale, hoverBox.y - 2 / scale, hoverBox.w + 4 / scale, hoverBox.h + 4 / scale);
|
|
960
|
+
g.restore();
|
|
961
|
+
}
|
|
962
|
+
}
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/** 四角手柄(原图像素坐标)。 */
|
|
966
|
+
function handlePoints(box) {
|
|
967
|
+
return [
|
|
968
|
+
{ x: box.x, y: box.y, corner: 'nw' },
|
|
969
|
+
{ x: box.x + box.w, y: box.y, corner: 'ne' },
|
|
970
|
+
{ x: box.x, y: box.y + box.h, corner: 'sw' },
|
|
971
|
+
{ x: box.x + box.w, y: box.y + box.h, corner: 'se' }
|
|
972
|
+
];
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
/** 命中哪个手柄(返回角名或 null)。 */
|
|
976
|
+
function hitHandle(point) {
|
|
977
|
+
if (state.selected === null) return null;
|
|
978
|
+
var box = strokeBox(state.selected);
|
|
979
|
+
if (box === null) return null;
|
|
980
|
+
var radius = 10 / currentScale();
|
|
981
|
+
var handles = handlePoints(box);
|
|
982
|
+
for (var i = 0; i < handles.length; i++) {
|
|
983
|
+
if (Math.abs(point.x - handles[i].x) <= radius && Math.abs(point.y - handles[i].y) <= radius) {
|
|
984
|
+
return handles[i].corner;
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
return null;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
function currentScale() {
|
|
991
|
+
var rect = state.rect || img.getBoundingClientRect();
|
|
992
|
+
return Math.max(0.0001, rect.width / Math.max(1, state.natural.w));
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
/** 从最上层往下找被点中的那一笔。 */
|
|
996
|
+
function pickStroke(point, threshold) {
|
|
997
|
+
for (var i = state.strokes.length - 1; i >= 0; i--) {
|
|
998
|
+
if (hitStroke(state.strokes[i], point, threshold)) return state.strokes[i];
|
|
999
|
+
}
|
|
1000
|
+
return null;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
/** 命中容差:屏幕上约 9px,换算回原图像素。 */
|
|
1004
|
+
function selectionThreshold() {
|
|
1005
|
+
return Math.max(currentWidth() * 0.9, 9 / Math.max(currentScale(), 0.02));
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
function toNatural(event) {
|
|
1009
|
+
var rect = state.rect || img.getBoundingClientRect();
|
|
1010
|
+
return {
|
|
1011
|
+
x: ((event.clientX - rect.left) / Math.max(1, rect.width)) * state.natural.w,
|
|
1012
|
+
y: ((event.clientY - rect.top) / Math.max(1, rect.height)) * state.natural.h
|
|
1013
|
+
};
|
|
1014
|
+
}
|
|
1015
|
+
|
|
1016
|
+
/* ------------------------------ 历史 ------------------------------ */
|
|
1017
|
+
|
|
1018
|
+
function snapshot() {
|
|
1019
|
+
return cloneStrokes(state.strokes);
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
function pushSnapshot(snap) {
|
|
1023
|
+
state.past.push(snap);
|
|
1024
|
+
if (state.past.length > 80) state.past.shift();
|
|
1025
|
+
state.future.length = 0;
|
|
1026
|
+
refreshButtons();
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
function pushHistory() {
|
|
1030
|
+
pushSnapshot(snapshot());
|
|
1031
|
+
}
|
|
1032
|
+
|
|
1033
|
+
function undo() {
|
|
1034
|
+
if (state.past.length === 0) return;
|
|
1035
|
+
state.future.push(snapshot());
|
|
1036
|
+
state.strokes = state.past.pop();
|
|
1037
|
+
state.selected = null;
|
|
1038
|
+
draw();
|
|
1039
|
+
refreshButtons();
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
function redo() {
|
|
1043
|
+
if (state.future.length === 0) return;
|
|
1044
|
+
state.past.push(snapshot());
|
|
1045
|
+
state.strokes = state.future.pop();
|
|
1046
|
+
state.selected = null;
|
|
1047
|
+
draw();
|
|
1048
|
+
refreshButtons();
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
function clearAll() {
|
|
1052
|
+
if (state.strokes.length === 0) return;
|
|
1053
|
+
pushHistory();
|
|
1054
|
+
state.strokes = [];
|
|
1055
|
+
draw();
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
/* ------------------------------ 指针交互 ------------------------------ */
|
|
1059
|
+
|
|
1060
|
+
function hitStroke(stroke, point, threshold) {
|
|
1061
|
+
if (stroke.tool === 'text') {
|
|
1062
|
+
var size = stroke.fontSize || 20;
|
|
1063
|
+
var width = textWidthOf(stroke);
|
|
1064
|
+
return (
|
|
1065
|
+
point.x >= stroke.x - threshold &&
|
|
1066
|
+
point.x <= stroke.x + width + threshold &&
|
|
1067
|
+
point.y >= stroke.y - threshold &&
|
|
1068
|
+
point.y <= stroke.y + size * 1.25 + threshold
|
|
1069
|
+
);
|
|
1070
|
+
}
|
|
1071
|
+
var points = stroke.points || [];
|
|
1072
|
+
if (stroke.tool === 'rect') {
|
|
1073
|
+
var rectBox = strokeBounds(points);
|
|
1074
|
+
var nearX = Math.min(Math.abs(point.x - rectBox.minX), Math.abs(point.x - rectBox.maxX));
|
|
1075
|
+
var nearY = Math.min(Math.abs(point.y - rectBox.minY), Math.abs(point.y - rectBox.maxY));
|
|
1076
|
+
var within =
|
|
1077
|
+
point.x >= rectBox.minX - threshold &&
|
|
1078
|
+
point.x <= rectBox.maxX + threshold &&
|
|
1079
|
+
point.y >= rectBox.minY - threshold &&
|
|
1080
|
+
point.y <= rectBox.maxY + threshold;
|
|
1081
|
+
return within && Math.min(nearX, nearY) <= threshold;
|
|
1082
|
+
}
|
|
1083
|
+
if (stroke.tool === 'ellipse') {
|
|
1084
|
+
var ellipseBox = strokeBounds(points);
|
|
1085
|
+
var rx = ellipseBox.width / 2;
|
|
1086
|
+
var ry = ellipseBox.height / 2;
|
|
1087
|
+
if (rx > 0.5 && ry > 0.5) {
|
|
1088
|
+
var nx = (point.x - (ellipseBox.minX + ellipseBox.maxX) / 2) / rx;
|
|
1089
|
+
var ny = (point.y - (ellipseBox.minY + ellipseBox.maxY) / 2) / ry;
|
|
1090
|
+
return Math.abs(Math.sqrt(nx * nx + ny * ny) - 1) * Math.min(rx, ry) <= threshold;
|
|
1091
|
+
}
|
|
1092
|
+
return false;
|
|
1093
|
+
}
|
|
1094
|
+
if (points.length === 1) {
|
|
1095
|
+
return Math.hypot(point.x - points[0].x, point.y - points[0].y) <= threshold;
|
|
1096
|
+
}
|
|
1097
|
+
for (var i = 1; i < points.length; i++) {
|
|
1098
|
+
if (distanceToSegment(point.x, point.y, points[i - 1].x, points[i - 1].y, points[i].x, points[i].y) <= threshold) {
|
|
1099
|
+
return true;
|
|
1100
|
+
}
|
|
1101
|
+
}
|
|
1102
|
+
return false;
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
function eraseAt(point) {
|
|
1106
|
+
var threshold = Math.max(currentWidth() * 1.6, 9 / Math.max(currentScale(), 0.02));
|
|
1107
|
+
for (var i = state.strokes.length - 1; i >= 0; i--) {
|
|
1108
|
+
if (hitStroke(state.strokes[i], point, threshold)) {
|
|
1109
|
+
pushHistory();
|
|
1110
|
+
if (state.selected === state.strokes[i]) state.selected = null;
|
|
1111
|
+
state.strokes.splice(i, 1);
|
|
1112
|
+
draw();
|
|
1113
|
+
return true;
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
return false;
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
/* ------------------------------ 选择 / 移动 / 缩放 ------------------------------ */
|
|
1120
|
+
|
|
1121
|
+
function selectStroke(stroke) {
|
|
1122
|
+
if (state.selected === stroke) return;
|
|
1123
|
+
state.selected = stroke;
|
|
1124
|
+
draw();
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
function deleteSelected() {
|
|
1128
|
+
if (state.selected === null) return false;
|
|
1129
|
+
var index = state.strokes.indexOf(state.selected);
|
|
1130
|
+
if (index < 0) return false;
|
|
1131
|
+
pushHistory();
|
|
1132
|
+
state.strokes.splice(index, 1);
|
|
1133
|
+
state.selected = null;
|
|
1134
|
+
state.hovered = null;
|
|
1135
|
+
draw();
|
|
1136
|
+
return true;
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
function nudgeSelected(dx, dy) {
|
|
1140
|
+
if (state.selected === null) return false;
|
|
1141
|
+
var index = state.strokes.indexOf(state.selected);
|
|
1142
|
+
if (index < 0) return false;
|
|
1143
|
+
pushHistory();
|
|
1144
|
+
state.strokes[index] = translateStroke(state.selected, dx, dy);
|
|
1145
|
+
state.selected = state.strokes[index];
|
|
1146
|
+
draw();
|
|
1147
|
+
return true;
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
/** 拖动开始:记下原始笔迹,后续每帧都从原始状态重算,避免累积误差。 */
|
|
1151
|
+
function beginDrag(mode, point, corner) {
|
|
1152
|
+
if (state.selected === null) return false;
|
|
1153
|
+
var box = strokeBox(state.selected);
|
|
1154
|
+
if (box === null) return false;
|
|
1155
|
+
state.drag = {
|
|
1156
|
+
mode: mode,
|
|
1157
|
+
corner: corner || null,
|
|
1158
|
+
start: point,
|
|
1159
|
+
original: cloneStroke(state.selected),
|
|
1160
|
+
box: box,
|
|
1161
|
+
before: snapshot(),
|
|
1162
|
+
historyPushed: false
|
|
1163
|
+
};
|
|
1164
|
+
return true;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
function applyDrag(point) {
|
|
1168
|
+
var drag = state.drag;
|
|
1169
|
+
if (drag === null || state.selected === null) return;
|
|
1170
|
+
var index = state.strokes.indexOf(state.selected);
|
|
1171
|
+
if (index < 0) return;
|
|
1172
|
+
var moved = false;
|
|
1173
|
+
if (drag.mode === 'move') {
|
|
1174
|
+
var dx = point.x - drag.start.x;
|
|
1175
|
+
var dy = point.y - drag.start.y;
|
|
1176
|
+
if (dx === 0 && dy === 0) return;
|
|
1177
|
+
state.strokes[index] = translateStroke(drag.original, dx, dy);
|
|
1178
|
+
moved = true;
|
|
1179
|
+
} else {
|
|
1180
|
+
var originX = drag.corner === 'ne' || drag.corner === 'se' ? drag.box.x : drag.box.x + drag.box.w;
|
|
1181
|
+
var originY = drag.corner === 'sw' || drag.corner === 'se' ? drag.box.y : drag.box.y + drag.box.h;
|
|
1182
|
+
var startDistance = Math.hypot(drag.start.x - originX, drag.start.y - originY);
|
|
1183
|
+
var nowDistance = Math.hypot(point.x - originX, point.y - originY);
|
|
1184
|
+
if (startDistance < 1) return;
|
|
1185
|
+
var factor = clamp(nowDistance / startDistance, 0.15, 12);
|
|
1186
|
+
if (Math.abs(factor - 1) < 0.002) return;
|
|
1187
|
+
state.strokes[index] = scaleStroke(drag.original, factor, originX, originY);
|
|
1188
|
+
moved = true;
|
|
1189
|
+
}
|
|
1190
|
+
if (!moved) return;
|
|
1191
|
+
/* 真正改动过才落一条历史,纯点击不会污染撤销栈 */
|
|
1192
|
+
if (!drag.historyPushed) {
|
|
1193
|
+
drag.historyPushed = true;
|
|
1194
|
+
pushSnapshot(drag.before);
|
|
1195
|
+
}
|
|
1196
|
+
state.selected = state.strokes[index];
|
|
1197
|
+
draw();
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
function cursorFor(point) {
|
|
1201
|
+
if (state.tool !== 'select') return null;
|
|
1202
|
+
var corner = hitHandle(point);
|
|
1203
|
+
if (corner === 'nw' || corner === 'se') return 'nwse-resize';
|
|
1204
|
+
if (corner === 'ne' || corner === 'sw') return 'nesw-resize';
|
|
1205
|
+
var hovered = pickStroke(point, selectionThreshold());
|
|
1206
|
+
state.hovered = hovered;
|
|
1207
|
+
return hovered !== null ? 'move' : 'default';
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
function onPointerDown(event) {
|
|
1211
|
+
if (!state.active || state.busy) return;
|
|
1212
|
+
if (event.button !== undefined && event.button !== 0) return;
|
|
1213
|
+
var point = toNatural(event);
|
|
1214
|
+
|
|
1215
|
+
if (state.tool === 'text') {
|
|
1216
|
+
event.preventDefault();
|
|
1217
|
+
var existing = pickStroke(point, selectionThreshold());
|
|
1218
|
+
if (existing !== null && existing.tool === 'text') {
|
|
1219
|
+
/* 文字工具里直接拖已有的文字框:不用切工具就能挪位置 */
|
|
1220
|
+
selectStroke(existing);
|
|
1221
|
+
beginDrag('move', point, null);
|
|
1222
|
+
state.drawing = true;
|
|
1223
|
+
try {
|
|
1224
|
+
canvas.setPointerCapture(event.pointerId);
|
|
1225
|
+
} catch (ignored) {
|
|
1226
|
+
/* 忽略 */
|
|
1227
|
+
}
|
|
1228
|
+
return;
|
|
1229
|
+
}
|
|
1230
|
+
openTextEditor(event);
|
|
1231
|
+
return;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
event.preventDefault();
|
|
1235
|
+
try {
|
|
1236
|
+
canvas.setPointerCapture(event.pointerId);
|
|
1237
|
+
} catch (ignored) {
|
|
1238
|
+
/* 忽略 */
|
|
1239
|
+
}
|
|
1240
|
+
|
|
1241
|
+
if (state.tool === 'select') {
|
|
1242
|
+
var corner = hitHandle(point);
|
|
1243
|
+
if (corner !== null) {
|
|
1244
|
+
state.drawing = true;
|
|
1245
|
+
beginDrag('resize', point, corner);
|
|
1246
|
+
return;
|
|
1247
|
+
}
|
|
1248
|
+
var hit = pickStroke(point, selectionThreshold());
|
|
1249
|
+
if (hit !== null) {
|
|
1250
|
+
selectStroke(hit);
|
|
1251
|
+
state.drawing = true;
|
|
1252
|
+
beginDrag('move', point, null);
|
|
1253
|
+
return;
|
|
1254
|
+
}
|
|
1255
|
+
state.selected = null;
|
|
1256
|
+
state.hovered = null;
|
|
1257
|
+
draw();
|
|
1258
|
+
refreshButtons();
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
|
|
1262
|
+
state.drawing = true;
|
|
1263
|
+
if (state.tool === 'eraser') {
|
|
1264
|
+
eraseAt(point);
|
|
1265
|
+
return;
|
|
1266
|
+
}
|
|
1267
|
+
state.draft = {
|
|
1268
|
+
tool: state.tool,
|
|
1269
|
+
color: tokenColor(paletteEntry(state.color)),
|
|
1270
|
+
width: currentWidth(),
|
|
1271
|
+
points: [point, { x: point.x, y: point.y }]
|
|
1272
|
+
};
|
|
1273
|
+
draw();
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
var lastCursor = '';
|
|
1277
|
+
|
|
1278
|
+
function setCursor(value) {
|
|
1279
|
+
if (value === lastCursor) return;
|
|
1280
|
+
lastCursor = value;
|
|
1281
|
+
canvas.style.cursor = value;
|
|
1282
|
+
}
|
|
1283
|
+
|
|
1284
|
+
function onPointerMove(event) {
|
|
1285
|
+
var point = toNatural(event);
|
|
1286
|
+
|
|
1287
|
+
if (!state.drawing) {
|
|
1288
|
+
var cursor = cursorFor(point);
|
|
1289
|
+
setCursor(cursor === null ? (state.tool === 'select' ? 'default' : 'crosshair') : cursor);
|
|
1290
|
+
if (state.tool === 'select' && state.hovered !== null && state.selected === null) draw();
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
if (state.drag !== null) {
|
|
1295
|
+
applyDrag(point);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1298
|
+
if (state.tool === 'eraser') {
|
|
1299
|
+
eraseAt(point);
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
if (state.draft === null) return;
|
|
1303
|
+
if (state.tool === 'pen') {
|
|
1304
|
+
var last = state.draft.points[state.draft.points.length - 1];
|
|
1305
|
+
if (Math.hypot(point.x - last.x, point.y - last.y) < state.natural.w * 0.0012) return;
|
|
1306
|
+
state.draft.points.push(point);
|
|
1307
|
+
} else if (state.tool === 'arrow') {
|
|
1308
|
+
state.draft.points[1] = point;
|
|
1309
|
+
} else {
|
|
1310
|
+
/* 方框 / 圆圈:累积轨迹,落笔时按外接框成形 */
|
|
1311
|
+
state.draft.points.push(point);
|
|
1312
|
+
}
|
|
1313
|
+
draw();
|
|
1314
|
+
}
|
|
1315
|
+
|
|
1316
|
+
function onPointerUp(event) {
|
|
1317
|
+
if (!state.drawing) return;
|
|
1318
|
+
state.drawing = false;
|
|
1319
|
+
try {
|
|
1320
|
+
canvas.releasePointerCapture(event.pointerId);
|
|
1321
|
+
} catch (ignored) {
|
|
1322
|
+
/* 忽略 */
|
|
1323
|
+
}
|
|
1324
|
+
if (state.drag !== null) {
|
|
1325
|
+
state.drag = null;
|
|
1326
|
+
draw();
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
if (state.draft !== null) {
|
|
1330
|
+
var draft = state.draft;
|
|
1331
|
+
state.draft = null;
|
|
1332
|
+
var box = strokeBounds(draft.points);
|
|
1333
|
+
if (draft.tool === 'pen' ? draft.points.length > 0 : box.width > 2 || box.height > 2) {
|
|
1334
|
+
pushHistory();
|
|
1335
|
+
state.strokes.push(draft);
|
|
1336
|
+
/* 刚画完就选中,方便马上调位置 */
|
|
1337
|
+
state.selected = draft;
|
|
1338
|
+
}
|
|
1339
|
+
draw();
|
|
1340
|
+
}
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
function onDoubleClick(event) {
|
|
1344
|
+
if (!state.active) return;
|
|
1345
|
+
var point = toNatural(event);
|
|
1346
|
+
var hit = pickStroke(point, selectionThreshold());
|
|
1347
|
+
if (hit !== null && hit.tool === 'text') {
|
|
1348
|
+
event.preventDefault();
|
|
1349
|
+
selectStroke(hit);
|
|
1350
|
+
openTextEditor(event, hit);
|
|
1351
|
+
}
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
canvas.addEventListener('pointerdown', onPointerDown);
|
|
1355
|
+
canvas.addEventListener('pointermove', onPointerMove);
|
|
1356
|
+
canvas.addEventListener('pointerup', onPointerUp);
|
|
1357
|
+
canvas.addEventListener('pointercancel', onPointerUp);
|
|
1358
|
+
canvas.addEventListener('dblclick', onDoubleClick);
|
|
1359
|
+
|
|
1360
|
+
/* ------------------------------ 文字工具 ------------------------------ */
|
|
1361
|
+
|
|
1362
|
+
function closeTextEditor() {
|
|
1363
|
+
if (state.textInput !== null) {
|
|
1364
|
+
var input = state.textInput;
|
|
1365
|
+
state.textInput = null;
|
|
1366
|
+
input.remove();
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* 打开文字输入框。
|
|
1372
|
+
*
|
|
1373
|
+
* @param event 触发事件(用来定输入框位置与落点)。
|
|
1374
|
+
* @param existing 传入已有文字笔迹时是「改字」,否则是新建。
|
|
1375
|
+
*/
|
|
1376
|
+
function openTextEditor(event, existing) {
|
|
1377
|
+
closeTextEditor();
|
|
1378
|
+
var point = toNatural(event);
|
|
1379
|
+
var isEdit = existing !== undefined && existing !== null;
|
|
1380
|
+
var input = document.createElement('input');
|
|
1381
|
+
input.className = 'dsa-textinput';
|
|
1382
|
+
input.placeholder = t('textPlaceholder');
|
|
1383
|
+
if (isEdit) input.value = existing.text || '';
|
|
1384
|
+
input.style.left = clamp(event.clientX - 40, 8, Math.max(8, window.innerWidth - 240)) + 'px';
|
|
1385
|
+
input.style.top = clamp(event.clientY - 16, 8, Math.max(8, window.innerHeight - 52)) + 'px';
|
|
1386
|
+
root.appendChild(input);
|
|
1387
|
+
state.textInput = input;
|
|
1388
|
+
var settled = false;
|
|
1389
|
+
function finish(commit) {
|
|
1390
|
+
if (settled) return;
|
|
1391
|
+
settled = true;
|
|
1392
|
+
var text = input.value.trim();
|
|
1393
|
+
closeTextEditor();
|
|
1394
|
+
if (!commit || text.length === 0) {
|
|
1395
|
+
draw();
|
|
1396
|
+
return;
|
|
1397
|
+
}
|
|
1398
|
+
if (isEdit) {
|
|
1399
|
+
var index = state.strokes.indexOf(existing);
|
|
1400
|
+
if (index < 0) return;
|
|
1401
|
+
pushHistory();
|
|
1402
|
+
var updated = cloneStroke(state.strokes[index]);
|
|
1403
|
+
updated.text = text;
|
|
1404
|
+
updated.textWidth = measureTextWidth(text, updated.fontSize || 20);
|
|
1405
|
+
state.strokes[index] = updated;
|
|
1406
|
+
state.selected = updated;
|
|
1407
|
+
draw();
|
|
1408
|
+
return;
|
|
1409
|
+
}
|
|
1410
|
+
var fontSize = currentWidth() * 7;
|
|
1411
|
+
pushHistory();
|
|
1412
|
+
var stroke = {
|
|
1413
|
+
tool: 'text',
|
|
1414
|
+
color: tokenColor(paletteEntry(state.color)),
|
|
1415
|
+
width: currentWidth(),
|
|
1416
|
+
fontSize: fontSize,
|
|
1417
|
+
textWidth: measureTextWidth(text, fontSize),
|
|
1418
|
+
x: point.x,
|
|
1419
|
+
y: point.y,
|
|
1420
|
+
text: text
|
|
1421
|
+
};
|
|
1422
|
+
state.strokes.push(stroke);
|
|
1423
|
+
state.selected = stroke;
|
|
1424
|
+
draw();
|
|
1425
|
+
}
|
|
1426
|
+
input.addEventListener('keydown', function (keyEvent) {
|
|
1427
|
+
keyEvent.stopPropagation();
|
|
1428
|
+
if (keyEvent.key === 'Enter') {
|
|
1429
|
+
keyEvent.preventDefault();
|
|
1430
|
+
finish(true);
|
|
1431
|
+
} else if (keyEvent.key === 'Escape') {
|
|
1432
|
+
keyEvent.preventDefault();
|
|
1433
|
+
finish(false);
|
|
1434
|
+
}
|
|
1435
|
+
});
|
|
1436
|
+
input.addEventListener('blur', function () {
|
|
1437
|
+
finish(true);
|
|
1438
|
+
});
|
|
1439
|
+
try {
|
|
1440
|
+
input.focus({ preventScroll: true });
|
|
1441
|
+
} catch (ignored) {
|
|
1442
|
+
input.focus();
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
/* ------------------------------ 工具栏 ------------------------------ */
|
|
1447
|
+
|
|
1448
|
+
var toolButtons = {};
|
|
1449
|
+
var swatchButtons = [];
|
|
1450
|
+
var widthButtons = [];
|
|
1451
|
+
var undoButton = null;
|
|
1452
|
+
var redoButton = null;
|
|
1453
|
+
var trashButton = null;
|
|
1454
|
+
var primaryButton = null;
|
|
1455
|
+
|
|
1456
|
+
function refreshButtons() {
|
|
1457
|
+
for (var id in toolButtons) {
|
|
1458
|
+
if (Object.prototype.hasOwnProperty.call(toolButtons, id)) {
|
|
1459
|
+
toolButtons[id].setAttribute('data-dsa-on', state.tool === id ? '1' : '0');
|
|
1460
|
+
}
|
|
1461
|
+
}
|
|
1462
|
+
swatchButtons.forEach(function (entry) {
|
|
1463
|
+
entry.el.setAttribute('data-dsa-on', entry.id === state.color ? '1' : '0');
|
|
1464
|
+
});
|
|
1465
|
+
widthButtons.forEach(function (entry, index) {
|
|
1466
|
+
entry.el.setAttribute('data-dsa-on', index === state.widthIndex ? '1' : '0');
|
|
1467
|
+
});
|
|
1468
|
+
if (undoButton !== null) undoButton.disabled = state.past.length === 0;
|
|
1469
|
+
if (redoButton !== null) redoButton.disabled = state.future.length === 0;
|
|
1470
|
+
if (trashButton !== null) {
|
|
1471
|
+
var hasSelection = state.selected !== null && state.strokes.indexOf(state.selected) >= 0;
|
|
1472
|
+
var label = hasSelection ? t('deleteSelected') : t('clear');
|
|
1473
|
+
trashButton.title = label;
|
|
1474
|
+
trashButton.setAttribute('aria-label', label);
|
|
1475
|
+
trashButton.disabled = hasSelection ? false : state.strokes.length === 0;
|
|
1476
|
+
}
|
|
1477
|
+
}
|
|
1478
|
+
|
|
1479
|
+
function buildBar() {
|
|
1480
|
+
TOOLS.forEach(function (tool) {
|
|
1481
|
+
var button = iconButton(tool.icon, t(tool.id));
|
|
1482
|
+
button.dataset.dsaTool = tool.id;
|
|
1483
|
+
button.addEventListener('click', function () {
|
|
1484
|
+
state.tool = tool.id;
|
|
1485
|
+
if (tool.id !== 'select') state.hovered = null;
|
|
1486
|
+
closeTextEditor();
|
|
1487
|
+
refreshButtons();
|
|
1488
|
+
setCursor(tool.id === 'select' ? 'default' : 'crosshair');
|
|
1489
|
+
draw();
|
|
1490
|
+
});
|
|
1491
|
+
toolButtons[tool.id] = button;
|
|
1492
|
+
bar.appendChild(button);
|
|
1493
|
+
});
|
|
1494
|
+
|
|
1495
|
+
var sep1 = document.createElement('span');
|
|
1496
|
+
sep1.className = 'dsa-sep';
|
|
1497
|
+
bar.appendChild(sep1);
|
|
1498
|
+
|
|
1499
|
+
PALETTE.forEach(function (color) {
|
|
1500
|
+
var swatch = document.createElement('button');
|
|
1501
|
+
swatch.type = 'button';
|
|
1502
|
+
swatch.className = 'dsa-swatch';
|
|
1503
|
+
swatch.dataset.dsaColor = color.id;
|
|
1504
|
+
swatch.style.background = 'var(' + color.token + ',' + color.fallback + ')';
|
|
1505
|
+
swatch.title = t('color_' + color.id);
|
|
1506
|
+
swatch.setAttribute('aria-label', t('color_' + color.id));
|
|
1507
|
+
swatch.addEventListener('click', function () {
|
|
1508
|
+
state.color = color.id;
|
|
1509
|
+
/* 已有选中文字时换色,直接改它,符合「选中即编辑」的直觉 */
|
|
1510
|
+
var index = state.selected === null ? -1 : state.strokes.indexOf(state.selected);
|
|
1511
|
+
if (index >= 0 && state.selected.tool === 'text') {
|
|
1512
|
+
pushHistory();
|
|
1513
|
+
var updated = cloneStroke(state.strokes[index]);
|
|
1514
|
+
updated.color = tokenColor(color);
|
|
1515
|
+
state.strokes[index] = updated;
|
|
1516
|
+
state.selected = updated;
|
|
1517
|
+
draw();
|
|
1518
|
+
}
|
|
1519
|
+
refreshButtons();
|
|
1520
|
+
});
|
|
1521
|
+
swatchButtons.push({ el: swatch, id: color.id });
|
|
1522
|
+
bar.appendChild(swatch);
|
|
1523
|
+
});
|
|
1524
|
+
|
|
1525
|
+
var sep2 = document.createElement('span');
|
|
1526
|
+
sep2.className = 'dsa-sep';
|
|
1527
|
+
bar.appendChild(sep2);
|
|
1528
|
+
|
|
1529
|
+
[3, 5.4, 9].forEach(function (size, index) {
|
|
1530
|
+
var button = document.createElement('button');
|
|
1531
|
+
button.type = 'button';
|
|
1532
|
+
button.className = 'dsa-width';
|
|
1533
|
+
button.dataset.dsaWidth = String(index);
|
|
1534
|
+
button.title = index === 0 ? '细' : index === 1 ? '中' : '粗';
|
|
1535
|
+
var dot = document.createElement('span');
|
|
1536
|
+
dot.style.width = size + 'px';
|
|
1537
|
+
dot.style.height = size + 'px';
|
|
1538
|
+
button.appendChild(dot);
|
|
1539
|
+
button.addEventListener('click', function () {
|
|
1540
|
+
state.widthIndex = index;
|
|
1541
|
+
refreshButtons();
|
|
1542
|
+
});
|
|
1543
|
+
widthButtons.push({ el: button });
|
|
1544
|
+
bar.appendChild(button);
|
|
1545
|
+
});
|
|
1546
|
+
|
|
1547
|
+
var sep3 = document.createElement('span');
|
|
1548
|
+
sep3.className = 'dsa-sep';
|
|
1549
|
+
bar.appendChild(sep3);
|
|
1550
|
+
|
|
1551
|
+
undoButton = iconButton(ICONS.undo, t('undo'));
|
|
1552
|
+
undoButton.dataset.dsaAction = 'undo';
|
|
1553
|
+
undoButton.addEventListener('click', undo);
|
|
1554
|
+
bar.appendChild(undoButton);
|
|
1555
|
+
|
|
1556
|
+
redoButton = iconButton(ICONS.redo, t('redo'));
|
|
1557
|
+
redoButton.dataset.dsaAction = 'redo';
|
|
1558
|
+
redoButton.addEventListener('click', redo);
|
|
1559
|
+
bar.appendChild(redoButton);
|
|
1560
|
+
|
|
1561
|
+
trashButton = iconButton(ICONS.trash, t('clear'));
|
|
1562
|
+
trashButton.dataset.dsaAction = 'clear';
|
|
1563
|
+
trashButton.addEventListener('click', function () {
|
|
1564
|
+
if (state.selected !== null && state.strokes.indexOf(state.selected) >= 0) deleteSelected();
|
|
1565
|
+
else clearAll();
|
|
1566
|
+
});
|
|
1567
|
+
bar.appendChild(trashButton);
|
|
1568
|
+
|
|
1569
|
+
var sep4 = document.createElement('span');
|
|
1570
|
+
sep4.className = 'dsa-sep';
|
|
1571
|
+
bar.appendChild(sep4);
|
|
1572
|
+
|
|
1573
|
+
primaryButton = document.createElement('button');
|
|
1574
|
+
primaryButton.type = 'button';
|
|
1575
|
+
primaryButton.className = 'dsa-primary';
|
|
1576
|
+
primaryButton.title = t('attachTitle');
|
|
1577
|
+
primaryButton.dataset.dsaAction = 'attach';
|
|
1578
|
+
primaryButton.appendChild(svgIcon(ICONS.check));
|
|
1579
|
+
var primaryLabel = document.createElement('span');
|
|
1580
|
+
primaryLabel.textContent = t('attach');
|
|
1581
|
+
primaryButton.appendChild(primaryLabel);
|
|
1582
|
+
primaryButton.addEventListener('click', function () {
|
|
1583
|
+
commit('attach');
|
|
1584
|
+
});
|
|
1585
|
+
bar.appendChild(primaryButton);
|
|
1586
|
+
|
|
1587
|
+
var downloadButton = iconButton(ICONS.download, t('download'));
|
|
1588
|
+
downloadButton.dataset.dsaAction = 'download';
|
|
1589
|
+
downloadButton.addEventListener('click', function () {
|
|
1590
|
+
commit('download');
|
|
1591
|
+
});
|
|
1592
|
+
bar.appendChild(downloadButton);
|
|
1593
|
+
|
|
1594
|
+
var exitButton = iconButton(ICONS.close, t('exit'));
|
|
1595
|
+
exitButton.dataset.dsaAction = 'exit';
|
|
1596
|
+
exitButton.addEventListener('click', function () {
|
|
1597
|
+
setActive(false);
|
|
1598
|
+
});
|
|
1599
|
+
bar.appendChild(exitButton);
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
buildBar();
|
|
1603
|
+
refreshButtons();
|
|
1604
|
+
|
|
1605
|
+
/* ------------------------------ 状态切换 ------------------------------ */
|
|
1606
|
+
|
|
1607
|
+
function setActive(active) {
|
|
1608
|
+
state.active = active;
|
|
1609
|
+
canvas.setAttribute('data-dsa-active', active ? '1' : '0');
|
|
1610
|
+
bar.style.display = active ? 'flex' : 'none';
|
|
1611
|
+
/* fab 用 grid 布局(原生圆形按钮),隐藏要用 none */
|
|
1612
|
+
fab.style.display = active ? 'none' : 'grid';
|
|
1613
|
+
if (!active) {
|
|
1614
|
+
closeTextEditor();
|
|
1615
|
+
state.selected = null;
|
|
1616
|
+
state.hovered = null;
|
|
1617
|
+
state.drag = null;
|
|
1618
|
+
canvas.style.cursor = '';
|
|
1619
|
+
}
|
|
1620
|
+
if (active) {
|
|
1621
|
+
canvas.style.cursor = state.tool === 'select' ? 'default' : 'crosshair';
|
|
1622
|
+
syncLayout();
|
|
1623
|
+
/* 预览可能有入场动画,短时间内跟随几帧 */
|
|
1624
|
+
var frames = 0;
|
|
1625
|
+
(function follow() {
|
|
1626
|
+
if (!state.active || frames++ > 40) return;
|
|
1627
|
+
syncLayout();
|
|
1628
|
+
state.rafId = requestAnimationFrame(follow);
|
|
1629
|
+
})();
|
|
1630
|
+
}
|
|
1631
|
+
}
|
|
1632
|
+
|
|
1633
|
+
fab.addEventListener('click', function () {
|
|
1634
|
+
setActive(true);
|
|
1635
|
+
});
|
|
1636
|
+
|
|
1637
|
+
/* ------------------------------ 提示 ------------------------------ */
|
|
1638
|
+
|
|
1639
|
+
function toast(message, duration) {
|
|
1640
|
+
toastEl.textContent = message;
|
|
1641
|
+
toastEl.style.display = 'block';
|
|
1642
|
+
if (state.timer) clearTimeout(state.timer);
|
|
1643
|
+
state.timer = setTimeout(function () {
|
|
1644
|
+
toastEl.style.display = 'none';
|
|
1645
|
+
}, duration || 3200);
|
|
1646
|
+
}
|
|
1647
|
+
|
|
1648
|
+
/* ------------------------------ 导出与投递 ------------------------------ */
|
|
1649
|
+
|
|
1650
|
+
function exportBlob() {
|
|
1651
|
+
return loadSource(img).then(function (loaded) {
|
|
1652
|
+
var width = state.natural.w || img.naturalWidth || 0;
|
|
1653
|
+
var height = state.natural.h || img.naturalHeight || 0;
|
|
1654
|
+
if (width < 2 || height < 2) throw new Error('image size unknown');
|
|
1655
|
+
var target = document.createElement('canvas');
|
|
1656
|
+
target.width = width;
|
|
1657
|
+
target.height = height;
|
|
1658
|
+
var g = target.getContext('2d');
|
|
1659
|
+
g.drawImage(loaded.source, 0, 0, width, height);
|
|
1660
|
+
for (var i = 0; i < state.strokes.length; i++) paintStroke(g, state.strokes[i]);
|
|
1661
|
+
loaded.release();
|
|
1662
|
+
return new Promise(function (resolve, reject) {
|
|
1663
|
+
target.toBlob(function (blob) {
|
|
1664
|
+
if (blob === null) reject(new Error('toBlob returned null'));
|
|
1665
|
+
else resolve(blob);
|
|
1666
|
+
}, 'image/png');
|
|
1667
|
+
});
|
|
1668
|
+
});
|
|
1669
|
+
}
|
|
1670
|
+
|
|
1671
|
+
function setBusy(busy) {
|
|
1672
|
+
state.busy = busy;
|
|
1673
|
+
if (primaryButton !== null) {
|
|
1674
|
+
primaryButton.setAttribute('data-dsa-busy', busy ? '1' : '0');
|
|
1675
|
+
primaryButton.disabled = busy;
|
|
1676
|
+
}
|
|
1677
|
+
}
|
|
1678
|
+
|
|
1679
|
+
function closeLightbox() {
|
|
1680
|
+
var close = dialog.querySelector(':scope > button');
|
|
1681
|
+
if (close !== null) {
|
|
1682
|
+
close.click();
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
|
|
1686
|
+
}
|
|
1687
|
+
|
|
1688
|
+
function commit(kind) {
|
|
1689
|
+
if (state.busy) return;
|
|
1690
|
+
if (kind === 'attach' && state.strokes.length === 0) {
|
|
1691
|
+
toast(t('empty'));
|
|
1692
|
+
return;
|
|
1693
|
+
}
|
|
1694
|
+
setBusy(true);
|
|
1695
|
+
var filename = 'annotated-' + stamp() + '.png';
|
|
1696
|
+
toast(t('working'), 60000);
|
|
1697
|
+
exportBlob()
|
|
1698
|
+
.then(function (blob) {
|
|
1699
|
+
if (kind === 'download') {
|
|
1700
|
+
downloadBlob(blob, filename);
|
|
1701
|
+
toast(t('downloaded'));
|
|
1702
|
+
return null;
|
|
1703
|
+
}
|
|
1704
|
+
var file = new File([blob], filename, { type: 'image/png' });
|
|
1705
|
+
var before = countBlobImages();
|
|
1706
|
+
var pasted = attachViaPaste(file);
|
|
1707
|
+
if (!pasted) {
|
|
1708
|
+
toast(t('noEditor'));
|
|
1709
|
+
return null;
|
|
1710
|
+
}
|
|
1711
|
+
return waitFor(function () {
|
|
1712
|
+
return countBlobImages() > before;
|
|
1713
|
+
}, 2200).then(function (appeared) {
|
|
1714
|
+
if (appeared) {
|
|
1715
|
+
log('attachment confirmed');
|
|
1716
|
+
toast(t('attached'));
|
|
1717
|
+
closeLightbox();
|
|
1718
|
+
return null;
|
|
1719
|
+
}
|
|
1720
|
+
log('attachment not confirmed; trying drop');
|
|
1721
|
+
attachViaDrop(file);
|
|
1722
|
+
return waitFor(function () {
|
|
1723
|
+
return countBlobImages() > before;
|
|
1724
|
+
}, 1200).then(function (dropped) {
|
|
1725
|
+
if (dropped) {
|
|
1726
|
+
toast(t('attached'));
|
|
1727
|
+
closeLightbox();
|
|
1728
|
+
return null;
|
|
1729
|
+
}
|
|
1730
|
+
return copyBlobToClipboard(blob).then(function (copied) {
|
|
1731
|
+
if (copied) {
|
|
1732
|
+
toast(t('uncertain'), 6000);
|
|
1733
|
+
closeLightbox();
|
|
1734
|
+
} else {
|
|
1735
|
+
downloadBlob(blob, filename);
|
|
1736
|
+
toast(t('downloaded'), 5000);
|
|
1737
|
+
}
|
|
1738
|
+
return null;
|
|
1739
|
+
});
|
|
1740
|
+
});
|
|
1741
|
+
});
|
|
1742
|
+
})
|
|
1743
|
+
.catch(function (error) {
|
|
1744
|
+
warn('export failed', error);
|
|
1745
|
+
toast(t('exportFailed') + (error && error.message ? ':' + error.message : ''), 5000);
|
|
1746
|
+
})
|
|
1747
|
+
.then(function () {
|
|
1748
|
+
setBusy(false);
|
|
1749
|
+
});
|
|
1750
|
+
}
|
|
1751
|
+
|
|
1752
|
+
/* ------------------------------ 键盘快捷键 ------------------------------ */
|
|
1753
|
+
|
|
1754
|
+
function onKeyDown(event) {
|
|
1755
|
+
if (!state.active) return;
|
|
1756
|
+
/* 正在输入文字时,键盘归输入框 */
|
|
1757
|
+
if (state.textInput !== null) return;
|
|
1758
|
+
var modifier = event.ctrlKey || event.metaKey;
|
|
1759
|
+
if (modifier && (event.key === 'z' || event.key === 'Z')) {
|
|
1760
|
+
event.preventDefault();
|
|
1761
|
+
event.stopPropagation();
|
|
1762
|
+
if (event.shiftKey) redo();
|
|
1763
|
+
else undo();
|
|
1764
|
+
return;
|
|
1765
|
+
}
|
|
1766
|
+
if (modifier && (event.key === 'y' || event.key === 'Y')) {
|
|
1767
|
+
event.preventDefault();
|
|
1768
|
+
event.stopPropagation();
|
|
1769
|
+
redo();
|
|
1770
|
+
return;
|
|
1771
|
+
}
|
|
1772
|
+
if (modifier || event.altKey) return;
|
|
1773
|
+
|
|
1774
|
+
if (event.key === 'Delete' || event.key === 'Backspace') {
|
|
1775
|
+
if (deleteSelected()) {
|
|
1776
|
+
event.preventDefault();
|
|
1777
|
+
return;
|
|
1778
|
+
}
|
|
1779
|
+
}
|
|
1780
|
+
if (state.selected !== null && event.key.indexOf('Arrow') === 0) {
|
|
1781
|
+
var step = event.shiftKey ? 10 : 1;
|
|
1782
|
+
var dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0;
|
|
1783
|
+
var dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0;
|
|
1784
|
+
if (nudgeSelected(dx, dy)) {
|
|
1785
|
+
event.preventDefault();
|
|
1786
|
+
return;
|
|
1787
|
+
}
|
|
1788
|
+
}
|
|
1789
|
+
if (event.key === 'v' || event.key === 'V') {
|
|
1790
|
+
event.preventDefault();
|
|
1791
|
+
state.tool = 'select';
|
|
1792
|
+
refreshButtons();
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
|
|
1796
|
+
var order = ['select', 'pen', 'arrow', 'rect', 'ellipse', 'text', 'eraser'];
|
|
1797
|
+
var next = -1;
|
|
1798
|
+
if (event.key >= '1' && event.key <= '7') next = Number(event.key) - 1;
|
|
1799
|
+
else if (event.key === 'e' || event.key === 'E') next = state.tool === 'eraser' ? 1 : 6;
|
|
1800
|
+
if (next >= 0 && next < order.length) {
|
|
1801
|
+
event.preventDefault();
|
|
1802
|
+
state.tool = order[next];
|
|
1803
|
+
if (state.tool !== 'select') state.hovered = null;
|
|
1804
|
+
closeTextEditor();
|
|
1805
|
+
refreshButtons();
|
|
1806
|
+
draw();
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
window.addEventListener('keydown', onKeyDown, true);
|
|
1811
|
+
|
|
1812
|
+
/* ------------------------------ 位置同步与生命周期 ------------------------------ */
|
|
1813
|
+
|
|
1814
|
+
var resizeObserver = null;
|
|
1815
|
+
if (typeof ResizeObserver === 'function') {
|
|
1816
|
+
resizeObserver = new ResizeObserver(function () {
|
|
1817
|
+
syncLayout();
|
|
1818
|
+
});
|
|
1819
|
+
try {
|
|
1820
|
+
resizeObserver.observe(img);
|
|
1821
|
+
} catch (ignored) {
|
|
1822
|
+
/* 忽略 */
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
window.addEventListener('resize', syncLayout);
|
|
1826
|
+
img.addEventListener('load', syncLayout);
|
|
1827
|
+
if (!img.complete) {
|
|
1828
|
+
img.addEventListener('load', syncLayout, { once: true });
|
|
1829
|
+
}
|
|
1830
|
+
syncLayout();
|
|
1831
|
+
setTimeout(syncLayout, 60);
|
|
1832
|
+
setTimeout(syncLayout, 240);
|
|
1833
|
+
|
|
1834
|
+
function destroy() {
|
|
1835
|
+
window.removeEventListener('keydown', onKeyDown, true);
|
|
1836
|
+
window.removeEventListener('resize', syncLayout);
|
|
1837
|
+
img.removeEventListener('load', syncLayout);
|
|
1838
|
+
if (resizeObserver !== null) resizeObserver.disconnect();
|
|
1839
|
+
if (state.rafId) cancelAnimationFrame(state.rafId);
|
|
1840
|
+
if (state.timer) clearTimeout(state.timer);
|
|
1841
|
+
closeTextEditor();
|
|
1842
|
+
root.remove();
|
|
1843
|
+
var registry = window.__dshImageAnnotate;
|
|
1844
|
+
if (registry) {
|
|
1845
|
+
var index = registry.annotators.indexOf(handle);
|
|
1846
|
+
if (index >= 0) registry.annotators.splice(index, 1);
|
|
1847
|
+
}
|
|
1848
|
+
}
|
|
1849
|
+
|
|
1850
|
+
var handle = { dialog: dialog, state: state, setActive: setActive, destroy: destroy };
|
|
1851
|
+
var registry = window.__dshImageAnnotate;
|
|
1852
|
+
if (registry) registry.annotators.push(handle);
|
|
1853
|
+
return handle;
|
|
1854
|
+
}
|
|
1855
|
+
|
|
1856
|
+
/* ==================================================================
|
|
1857
|
+
* lightbox 观察器
|
|
1858
|
+
* ================================================================== */
|
|
1859
|
+
|
|
1860
|
+
function isLightbox(dialog) {
|
|
1861
|
+
/* 结构(dsh-client-ui-primitives ImageLightbox):
|
|
1862
|
+
* body portal > div[role=dialog][aria-modal=true]
|
|
1863
|
+
* > div[aria-hidden=true](遮罩), img(原图), button(关闭)
|
|
1864
|
+
* 这里只依赖「dialog 直接子元素里有 img」这个稳定特征。 */
|
|
1865
|
+
if (dialog.getAttribute('role') !== 'dialog') return false;
|
|
1866
|
+
if (dialog.getAttribute('aria-modal') !== 'true') return false;
|
|
1867
|
+
var img = dialog.querySelector(':scope > img');
|
|
1868
|
+
return img !== null;
|
|
1869
|
+
}
|
|
1870
|
+
|
|
1871
|
+
function startWatcher(t) {
|
|
1872
|
+
var annotators = [];
|
|
1873
|
+
var known = new WeakSet();
|
|
1874
|
+
var scheduled = false;
|
|
1875
|
+
var lastRun = 0;
|
|
1876
|
+
|
|
1877
|
+
function reconcile() {
|
|
1878
|
+
scheduled = false;
|
|
1879
|
+
lastRun = Date.now();
|
|
1880
|
+
for (var i = annotators.length - 1; i >= 0; i--) {
|
|
1881
|
+
if (!annotators[i].dialog.isConnected) {
|
|
1882
|
+
try {
|
|
1883
|
+
annotators[i].destroy();
|
|
1884
|
+
} catch (error) {
|
|
1885
|
+
warn('destroy failed', error);
|
|
1886
|
+
}
|
|
1887
|
+
annotators.splice(i, 1);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1890
|
+
var dialogs = document.body.querySelectorAll(':scope > div[role="dialog"][aria-modal="true"]');
|
|
1891
|
+
for (var j = 0; j < dialogs.length; j++) {
|
|
1892
|
+
var dialog = dialogs[j];
|
|
1893
|
+
if (known.has(dialog)) continue;
|
|
1894
|
+
if (!isLightbox(dialog)) continue;
|
|
1895
|
+
var img = dialog.querySelector(':scope > img');
|
|
1896
|
+
known.add(dialog);
|
|
1897
|
+
try {
|
|
1898
|
+
annotators.push(createAnnotator(dialog, img, t));
|
|
1899
|
+
log('annotator attached');
|
|
1900
|
+
} catch (error) {
|
|
1901
|
+
warn('attach failed', error);
|
|
1902
|
+
}
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
function schedule() {
|
|
1907
|
+
if (scheduled) return;
|
|
1908
|
+
var elapsed = Date.now() - lastRun;
|
|
1909
|
+
if (elapsed > 220) {
|
|
1910
|
+
reconcile();
|
|
1911
|
+
return;
|
|
1912
|
+
}
|
|
1913
|
+
scheduled = true;
|
|
1914
|
+
setTimeout(function () {
|
|
1915
|
+
if (scheduled) reconcile();
|
|
1916
|
+
}, 220 - elapsed);
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
var observer = new MutationObserver(schedule);
|
|
1920
|
+
observer.observe(document.body, { childList: true, subtree: true });
|
|
1921
|
+
reconcile();
|
|
1922
|
+
|
|
1923
|
+
return function stop() {
|
|
1924
|
+
observer.disconnect();
|
|
1925
|
+
annotators.forEach(function (annotator) {
|
|
1926
|
+
try {
|
|
1927
|
+
annotator.destroy();
|
|
1928
|
+
} catch (error) {
|
|
1929
|
+
warn('destroy failed', error);
|
|
1930
|
+
}
|
|
1931
|
+
});
|
|
1932
|
+
annotators.length = 0;
|
|
1933
|
+
};
|
|
1934
|
+
}
|
|
1935
|
+
|
|
1936
|
+
/* ==================================================================
|
|
1937
|
+
* 插件入口
|
|
1938
|
+
* ================================================================== */
|
|
1939
|
+
|
|
1940
|
+
function apply(ctx) {
|
|
1941
|
+
if (typeof window === 'undefined' || typeof document === 'undefined') return;
|
|
1942
|
+
var t = makeTranslator(ctx);
|
|
1943
|
+
injectStyles();
|
|
1944
|
+
/* 诊断入口:控制台里 `__dshImageAnnotate.annotators[0].state` 可看实时状态 */
|
|
1945
|
+
if (!window.__dshImageAnnotate) {
|
|
1946
|
+
window.__dshImageAnnotate = { version: '1.0.0', package: PKG_ID, annotators: [] };
|
|
1947
|
+
}
|
|
1948
|
+
var stop = startWatcher(t);
|
|
1949
|
+
if (ctx && typeof ctx.effect === 'function') {
|
|
1950
|
+
ctx.effect(function () {
|
|
1951
|
+
return stop;
|
|
1952
|
+
});
|
|
1953
|
+
} else {
|
|
1954
|
+
window.addEventListener('beforeunload', stop);
|
|
1955
|
+
}
|
|
1956
|
+
log('ready (v1.0.0)');
|
|
1957
|
+
}
|
|
1958
|
+
|
|
1959
|
+
exports.apply = apply;
|
|
1960
|
+
exports.inject = [];
|
|
1961
|
+
return module.exports;
|
|
1962
|
+
}
|
|
1963
|
+
});
|
|
1964
|
+
|
|
1965
|
+
//# sourceMappingURL=client.js.map
|