/** * dsh-image-annotate — 浏览器半区(client half)。 * * 给 DSH Web 的图片预览(ImageLightbox,body 级 portal 的 * `div[role="dialog"][aria-modal="true"] > img`)加一支画笔: * * 1. 预览打开时,右下角出现「标注」按钮; * 2. 点它进入标注模式:画笔 / 箭头 / 方框 / 圆圈 / 文字 / 擦除, * 可选颜色与粗细,支持撤销、重做、清空; * 3. 「加入输入框」把原图与标注合成一张 PNG,塞进当前会话的输入框 * (优先合成 paste 事件,失败退回拖放、剪贴板、下载三条路), * 关掉预览,用户接着打字发送即可——模型看到的就是标注后的图。 * * 设计取舍: * - 不依赖任何 dsh 内部包/服务:整包就是一个 `window.__ModuleLoader__.load` * 工厂,手写、无构建步骤,改动即被 client-modules 的 HMR 按 mtime 感知。 * - 不注册 slot、不接管原有 lightbox:只在预览 DOM 上叠加一层, * 预览关闭即整体消失,宿主 React 重渲染也不会被我们改坏。 * - 所有笔迹都以「原图像素坐标」存储,因此叠加显示、导出、 * 窗口缩放三者永远一致。 */ window.__ModuleLoader__.load({ id: "@dsh-plugin/dsh-image-annotate", factory: (require) => { 'use strict'; var module = { exports: {} }; var exports = module.exports; /* ================================================================== * 常量与基础工具 * ================================================================== */ var PKG_ID = '@dsh-plugin/dsh-image-annotate'; var LOG_PREFIX = '[image-annotate]'; /** 调试开关:localStorage['@dsh-plugin/dsh-image-annotate.debug'] = '1' */ function debugEnabled() { try { return localStorage.getItem(PKG_ID + '.debug') === '1'; } catch (error) { return false; } } function log() { if (!debugEnabled()) return; var args = Array.prototype.slice.call(arguments); args.unshift(LOG_PREFIX); console.log.apply(console, args); } function warn() { var args = Array.prototype.slice.call(arguments); args.unshift(LOG_PREFIX); console.warn.apply(console, args); } function clamp(value, min, max) { return Math.max(min, Math.min(max, value)); } function stamp() { var d = new Date(); function pad(n) { return n < 10 ? '0' + n : String(n); } return ( String(d.getFullYear()) + pad(d.getMonth() + 1) + pad(d.getDate()) + '-' + pad(d.getHours()) + pad(d.getMinutes()) + pad(d.getSeconds()) ); } function svgIcon(pathData) { var NS = 'http://www.w3.org/2000/svg'; var svg = document.createElementNS(NS, 'svg'); svg.setAttribute('viewBox', '0 0 24 24'); svg.setAttribute('width', '16'); svg.setAttribute('height', '16'); svg.setAttribute('fill', 'none'); svg.setAttribute('stroke', 'currentColor'); svg.setAttribute('stroke-width', '1.7'); svg.setAttribute('stroke-linecap', 'round'); svg.setAttribute('stroke-linejoin', 'round'); svg.setAttribute('aria-hidden', 'true'); var path = document.createElementNS(NS, 'path'); path.setAttribute('d', pathData); svg.appendChild(path); return svg; } function iconButton(iconPath, label, extraClass) { var button = document.createElement('button'); button.type = 'button'; button.className = 'dsa-btn' + (extraClass ? ' ' + extraClass : ''); button.title = label; button.setAttribute('aria-label', label); button.appendChild(svgIcon(iconPath)); return button; } /* ================================================================== * 文案(中英双语,跟随界面语言) * ================================================================== */ var DICT = { zh: { annotate: '标注', annotateTitle: '标注图片:圈画后把标注图发给 DSH', select: '选择 / 移动 (V)', pen: '画笔', arrow: '箭头', rect: '方框', ellipse: '圆圈', text: '文字', eraser: '擦除', undo: '撤销 (Ctrl+Z)', redo: '重做 (Ctrl+Shift+Z)', clear: '清空全部', deleteSelected: '删除选中 (Delete)', attach: '加入输入框', attachTitle: '把标注后的图片放进输入框', download: '下载标注图 PNG', exit: '退出标注', textPlaceholder: '打字后回车', empty: '先画点什么再发送吧', working: '正在生成标注图…', attached: '已加入输入框,输入文字后发送即可', uncertain: '已复制到剪贴板,按 Ctrl+V 贴进输入框', downloaded: '标注图已下载', exportFailed: '导出标注图失败', noEditor: '没找到输入框:请先打开一个会话再标注', selected: '已选中:拖动移动,双击文字改字,Delete 删除', color_red: '红色', color_amber: '橙色', color_yellow: '黄色', color_green: '绿色', color_blue: '蓝色', color_brand: '品牌蓝', color_ink: '黑色', color_paper: '白色' }, en: { annotate: 'Annotate', annotateTitle: 'Annotate the image, then send the marked copy to DSH', select: 'Select / move (V)', pen: 'Pen', arrow: 'Arrow', rect: 'Box', ellipse: 'Circle', text: 'Text', eraser: 'Erase', undo: 'Undo (Ctrl+Z)', redo: 'Redo (Ctrl+Shift+Z)', clear: 'Clear all', deleteSelected: 'Delete selection (Delete)', attach: 'Add to composer', attachTitle: 'Put the annotated image into the composer', download: 'Download annotated PNG', exit: 'Exit annotation', textPlaceholder: 'Type, then Enter', empty: 'Draw something first', working: 'Rendering annotated image…', attached: 'Added to the composer — type your message and send', uncertain: 'Copied to clipboard — press Ctrl+V in the composer', downloaded: 'Annotated PNG downloaded', exportFailed: 'Failed to export the annotated image', noEditor: 'No composer found — open a session first', selected: 'Selected: drag to move, double-click text to edit, Delete to remove', color_red: 'Red', color_amber: 'Amber', color_yellow: 'Yellow', color_green: 'Green', color_blue: 'Blue', color_brand: 'Brand blue', color_ink: 'Black', color_paper: 'White' } }; function detectLocale(ctx) { try { var locale = ctx && typeof ctx.get === 'function' ? ctx.get('locale') : undefined; var snapshot = locale && typeof locale.getLocale === 'function' ? locale.getLocale() : undefined; var id = snapshot && (snapshot.id || snapshot.locale || snapshot.language); if (typeof id === 'string' && id.length > 0) { return id.toLowerCase().indexOf('zh') === 0 ? 'zh' : 'en'; } } catch (error) { /* 忽略:退回 navigator.language */ } var nav = typeof navigator !== 'undefined' ? navigator.language || '' : ''; return nav.toLowerCase().indexOf('zh') === 0 ? 'zh' : 'en'; } function makeTranslator(ctx) { var lang = detectLocale(ctx); var table = DICT[lang] || DICT.en; return function t(key) { var value = table[key]; return value === undefined ? key : value; }; } /* ================================================================== * 样式(只注入一次;用 dsh 主题 token,取不到时用暗色兜底) * ================================================================== */ var STYLE_TAG_ID = PKG_ID + '/styles.css'; function injectStyles() { if (document.querySelector('style[data-plugin-css="' + STYLE_TAG_ID + '"]') !== null) return; var style = document.createElement('style'); style.dataset.plugin = PKG_ID; style.dataset.pluginCss = STYLE_TAG_ID; style.textContent = [ /* 全部走 dsh 原生设计 token(theme 包里的 --dsw-*), * 取不到时退回同样的暗色近似值,保证任何主题下都不跑偏。 */ '[data-dsa-root]{position:fixed;inset:0;pointer-events:none;z-index:2147482000;' + 'font-family:var(--dsw-font-family,-apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC",' + '"Hiragino Sans GB","Microsoft YaHei",sans-serif);font-size:14px;line-height:22px;' + 'color:var(--dsw-alias-label-primary,#0f1115);-webkit-font-smoothing:antialiased}', '.dsa-canvas{position:fixed;pointer-events:none;touch-action:none;' + 'border-radius:var(--dsw-radius-lg,12px);box-shadow:0 0 0 .5px var(--dsw-alias-border-l2-darkmode-thin,#0000001a)}', '.dsa-canvas[data-dsa-active="1"]{pointer-events:auto}', /* 「标注」入口 = 预览右上角关闭按钮的同款圆形按钮(右下角) */ '.dsa-fab{position:fixed;right:20px;bottom:20px;pointer-events:auto;display:grid;place-items:center;' + 'width:36px;height:36px;padding:0;border:.5px solid var(--dsw-alias-border-l2-darkmode-thin,#0000001a);' + 'border-radius:999px;corner-shape:round;background:var(--dsw-specific-input-major,#fff);' + 'color:var(--dsw-alias-label-primary,#0f1115);cursor:pointer;box-shadow:var(--dsw-elevation-panel)}', '.dsa-fab:hover{background:var(--dsw-alias-interactive-bg-hover-solid,#ebeef2)}', '.dsa-fab:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ' + 'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:3px}', /* 工具条 = 原生菜单浮层的材质与圆角 */ '.dsa-bar{position:fixed;left:50%;transform:translateX(-50%);bottom:20px;pointer-events:auto;display:flex;' + 'align-items:center;gap:2px;padding:4px;border-radius:var(--dsw-radius-lg,12px);' + 'background:var(--dsw-menu-surface-fill,var(--dsw-specific-input-major,#fff));' + 'backdrop-filter:var(--dsw-menu-backdrop-filter,blur(40px) saturate(150%));' + 'color:var(--dsw-alias-label-primary,#0f1115);box-shadow:var(--dsw-elevation-panel);' + /* left 已经占了 50%:不给 width:max-content 的话,盒子只按「left 右侧剩余空间」 收缩,稍宽就换行(原生 Toast 也踩过这个坑) */ 'width:max-content;max-width:min(96vw,940px);flex-wrap:wrap;justify-content:center}', '.dsa-btn{display:inline-flex;align-items:center;justify-content:center;width:30px;height:30px;padding:0;' + 'border:0;border-radius:var(--dsw-radius-md,8px);background:transparent;flex:none;' + 'color:var(--dsw-alias-label-secondary,#545557);cursor:pointer;' + 'transition:background .12s ease,color .12s ease}', '.dsa-btn:hover:not(:disabled){background:var(--dsw-alias-interactive-bg-hover,#2631480f);' + 'color:var(--dsw-alias-label-primary,#0f1115)}', '.dsa-btn[data-dsa-on="1"]{background:var(--dsw-alias-button-tool-bar-fill,#ebeef2);' + 'color:var(--dsw-alias-label-primary,#0f1115)}', '.dsa-btn[data-dsa-on="1"]:hover{background:var(--dsw-alias-button-tool-bar-hover,#e1e5ee)}', '.dsa-btn:disabled{opacity:.4;cursor:not-allowed}', '.dsa-btn:focus-visible{outline:var(--dsw-focus-ring-width,2px) solid ' + 'var(--dsw-focus-ring-color,var(--dsw-alias-state-business-primary,#4176e6));outline-offset:-2px}', '.dsa-sep{width:1px;height:16px;margin:0 3px;flex:none;background:var(--dsw-alias-border-l3,#0000001f)}', '.dsa-swatch{width:18px;height:18px;margin:0 2px;padding:0;flex:none;border:0;border-radius:999px;' + 'cursor:pointer;box-shadow:inset 0 0 0 1px #0000001f}', '.dsa-swatch[data-dsa-on="1"]{outline:2px solid var(--dsw-alias-state-business-primary,#4176e6);outline-offset:1px}', '.dsa-width{width:30px;height:30px;padding:0;flex:none;border:0;border-radius:var(--dsw-radius-md,8px);' + 'background:transparent;cursor:pointer;display:inline-flex;align-items:center;justify-content:center}', '.dsa-width:hover{background:var(--dsw-alias-interactive-bg-hover,#2631480f)}', '.dsa-width span{display:block;border-radius:999px;background:var(--dsw-alias-label-secondary,#545557)}', '.dsa-width[data-dsa-on="1"]{background:var(--dsw-alias-button-tool-bar-fill,#ebeef2)}', '.dsa-width[data-dsa-on="1"] span{background:var(--dsw-alias-label-primary,#0f1115)}', '.dsa-primary{display:inline-flex;align-items:center;gap:4px;height:30px;margin-left:3px;padding:0 12px;' + 'border:0;border-radius:var(--dsw-radius-md,8px);background:var(--dsw-alias-button-primary-fill,#4176e6);' + 'color:var(--dsw-alias-label-primary-foreground,#fff);font-size:13px;line-height:20px;font-weight:500;' + 'cursor:pointer;white-space:nowrap}', '.dsa-primary:hover:not(:disabled){background:var(--dsw-alias-button-primary-hover,#5686fe)}', '.dsa-primary[data-dsa-busy="1"]{opacity:.6;cursor:progress}', /* 提示条沿用原生 Toast 的面板:顶部居中、固定深色底 */ '.dsa-toast{position:fixed;top:40px;left:50%;transform:translateX(-50%);pointer-events:none;' + 'width:max-content;max-width:min(640px,calc(100vw - 48px));padding:12px 16px;text-align:center;' + 'border-radius:var(--dsw-radius-lg,12px);background:var(--dsw-alias-toast-bg,#2b3244);' + 'color:var(--dsw-alias-toast-label,#fff);font-size:14px;line-height:22px;box-shadow:var(--dsw-shadow-lv3)}', '.dsa-textinput{position:fixed;pointer-events:auto;min-width:160px;height:32px;padding:0 10px;' + 'border:1px solid var(--dsw-alias-state-business-primary,#4176e6);border-radius:var(--dsw-radius-md,8px);' + 'background:var(--dsw-alias-bg-layer-1,#fff);color:var(--dsw-alias-label-primary,#0f1115);' + 'font-family:inherit;font-size:14px;outline:none;box-shadow:var(--dsw-elevation-soft)}', '@media (prefers-reduced-motion:reduce){.dsa-btn,.dsa-fab{transition:none}}' ].join('\n'); document.head.appendChild(style); } /* ================================================================== * 画笔定义(色板直接引用 dsh 原生色 token,随主题走) * ================================================================== */ var PALETTE = [ { id: 'red', token: '--dsw-static-red-500', fallback: '#e5484d' }, { id: 'amber', token: '--dsw-static-amber-500', fallback: '#f59e0b' }, { id: 'yellow', token: '--dsw-static-amber-400', fallback: '#f7ad31' }, { id: 'green', token: '--dsw-static-green-500', fallback: '#22c55e' }, { id: 'blue', token: '--dsw-static-blue-500', fallback: '#3b82f6' }, { id: 'brand', token: '--dsw-static-deepseek-450', fallback: '#5686fe' }, { id: 'ink', token: '--dsw-static-neutral-1000', fallback: '#000000' }, { id: 'paper', token: '--dsw-static-neutral-00', fallback: '#ffffff' } ]; /** 主题 token → 具体颜色值(canvas 只能吃具体值)。 */ var colorCache = {}; function tokenColor(entry) { if (Object.prototype.hasOwnProperty.call(colorCache, entry.token)) return colorCache[entry.token]; var value = ''; try { value = (getComputedStyle(document.body).getPropertyValue(entry.token) || '').trim(); } catch (error) { value = ''; } if (value.length === 0) value = entry.fallback; colorCache[entry.token] = value; return value; } function paletteEntry(id) { for (var i = 0; i < PALETTE.length; i++) { if (PALETTE[i].id === id) return PALETTE[i]; } return PALETTE[0]; } var TOOLS = [ /* 「选择 / 移动」在最前:画完的每一笔都能再选回来拖位置 */ { 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' }, { 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' }, { id: 'arrow', icon: 'M5 19L19 5M19 5h-6.4M19 5v6.4' }, { id: 'rect', icon: 'M4.5 5.5h15v13h-15z' }, { 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' }, { id: 'text', icon: 'M5 6.5h14M12 6.5V19M9 19h6' }, { id: 'eraser', icon: 'M4.6 15.4l7-7 6.4 6.4-4 4H9.2l-4.6-3.4zM8.4 19.8h12' } ]; var ICONS = { undo: 'M9 15L4.5 10.5 9 6M4.5 10.5h9.4a5.6 5.6 0 010 11.2H9', redo: 'M15 15l4.5-4.5L15 6M19.5 10.5H10.1a5.6 5.6 0 000 11.2H15', trash: 'M5 7.5h14M9.5 7.5V5h5v2.5M7 7.5l.8 12h8.4l.8-12', download: 'M12 4v11m0 0l-4-4m4 4l4-4M5 19.5h14', close: 'M6 6l12 12M18 6L6 18', check: 'M20 6.6L9.5 17.4 4 11.9', 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' }; /* ================================================================== * 笔迹绘制:坐标全部是「原图像素」,因此缩放/导出共用同一套代码 * ================================================================== */ function strokeBounds(points) { var minX = Infinity; var minY = Infinity; var maxX = -Infinity; var maxY = -Infinity; for (var i = 0; i < points.length; i++) { var point = points[i]; if (point.x < minX) minX = point.x; if (point.y < minY) minY = point.y; if (point.x > maxX) maxX = point.x; if (point.y > maxY) maxY = point.y; } return { minX: minX, minY: minY, maxX: maxX, maxY: maxY, width: maxX - minX, height: maxY - minY }; } function paintStroke(g, stroke) { var points = stroke.points || []; g.save(); g.lineCap = 'round'; g.lineJoin = 'round'; g.strokeStyle = stroke.color; g.fillStyle = stroke.color; g.lineWidth = Math.max(0.6, stroke.width || 1); if (stroke.tool === 'pen') { if (points.length === 0) { /* 无点可画 */ } else if (points.length === 1) { g.beginPath(); g.arc(points[0].x, points[0].y, g.lineWidth / 2, 0, Math.PI * 2); g.fill(); } else { g.beginPath(); g.moveTo(points[0].x, points[0].y); for (var i = 1; i < points.length; i++) g.lineTo(points[i].x, points[i].y); g.stroke(); } } else if (stroke.tool === 'arrow') { var from = points[0]; var to = points[points.length - 1]; if (from && to) { var head = Math.max(g.lineWidth * 4.2, 9); var angle = Math.atan2(to.y - from.y, to.x - from.x); g.beginPath(); g.moveTo(from.x, from.y); g.lineTo(to.x, to.y); g.stroke(); g.beginPath(); g.moveTo(to.x, to.y); g.lineTo(to.x - head * Math.cos(angle - 0.42), to.y - head * Math.sin(angle - 0.42)); g.moveTo(to.x, to.y); g.lineTo(to.x - head * Math.cos(angle + 0.42), to.y - head * Math.sin(angle + 0.42)); g.stroke(); } } else if (stroke.tool === 'rect') { var rectBox = strokeBounds(points); if (rectBox.width > 0 || rectBox.height > 0) { g.strokeRect(rectBox.minX, rectBox.minY, rectBox.width, rectBox.height); } } else if (stroke.tool === 'ellipse') { /* 用整条轨迹的外接框,而不是「起点→终点」:这样手绘一圈 *(回到起点)与对角拖拽画椭圆都符合直觉。 */ var ellipseBox = strokeBounds(points); if (ellipseBox.width > 0 || ellipseBox.height > 0) { g.beginPath(); g.ellipse( (ellipseBox.minX + ellipseBox.maxX) / 2, (ellipseBox.minY + ellipseBox.maxY) / 2, ellipseBox.width / 2, ellipseBox.height / 2, 0, 0, Math.PI * 2 ); g.stroke(); } } else if (stroke.tool === 'text') { var size = stroke.fontSize || g.lineWidth * 7; g.font = textFont(size); g.textBaseline = 'top'; g.lineWidth = Math.max(1, size * 0.16); g.strokeStyle = 'rgba(255,255,255,.92)'; g.strokeText(stroke.text || '', stroke.x, stroke.y); g.fillStyle = stroke.color; g.fillText(stroke.text || '', stroke.x, stroke.y); } g.restore(); } function distanceToSegment(px, py, ax, ay, bx, by) { var dx = bx - ax; var dy = by - ay; var lengthSq = dx * dx + dy * dy; var t = lengthSq === 0 ? 0 : clamp(((px - ax) * dx + (py - ay) * dy) / lengthSq, 0, 1); var x = ax + t * dx; var y = ay + t * dy; return Math.sqrt((px - x) * (px - x) + (py - y) * (py - y)); } function cloneStrokes(strokes) { return strokes.map(cloneStroke); } function cloneStroke(stroke) { var copy = {}; for (var key in stroke) if (Object.prototype.hasOwnProperty.call(stroke, key)) copy[key] = stroke[key]; if (stroke.points) { copy.points = stroke.points.map(function (point) { return { x: point.x, y: point.y }; }); } return copy; } /* ================================================================== * 选中 / 移动 / 缩放:所有笔迹都以外接框为准(坐标仍是原图像素) * ================================================================== */ function textFont(size) { return ( '600 ' + size + 'px -apple-system,BlinkMacSystemFont,"Segoe UI","PingFang SC","Hiragino Sans GB","Microsoft YaHei",sans-serif' ); } var measureCanvas = null; /** 量一行文字的宽度(原图像素),用于选中框与擦除命中。 */ function measureTextWidth(text, fontSize) { try { if (measureCanvas === null) measureCanvas = document.createElement('canvas'); var g = measureCanvas.getContext('2d'); g.font = textFont(fontSize); return g.measureText(String(text)).width; } catch (error) { return fontSize * 0.62 * String(text).length; } } function textWidthOf(stroke) { var size = stroke.fontSize || 20; if (typeof stroke.textWidth === 'number' && stroke.textWidth > 0) return stroke.textWidth; return measureTextWidth(stroke.text || '', size); } /** 一笔的外接框(原图像素坐标);无点可框时返回 null。 */ function strokeBox(stroke) { if (stroke.tool === 'text') { var size = stroke.fontSize || 20; return { x: stroke.x, y: stroke.y, w: textWidthOf(stroke), h: size * 1.25 }; } var points = stroke.points || []; if (points.length === 0) return null; var bounds = strokeBounds(points); var pad = (stroke.width || 2) / 2; return { x: bounds.minX - pad, y: bounds.minY - pad, w: bounds.width + pad * 2, h: bounds.height + pad * 2 }; } function translateStroke(stroke, dx, dy) { var copy = cloneStroke(stroke); if (copy.points) { copy.points = copy.points.map(function (point) { return { x: point.x + dx, y: point.y + dy }; }); } if (copy.tool === 'text') { copy.x = stroke.x + dx; copy.y = stroke.y + dy; } return copy; } /** 绕 (originX, originY) 等比缩放一笔(文字同时缩字号)。 */ function scaleStroke(stroke, factor, originX, originY) { var copy = cloneStroke(stroke); if (copy.points) { copy.points = copy.points.map(function (point) { return { x: originX + (point.x - originX) * factor, y: originY + (point.y - originY) * factor }; }); } if (copy.tool === 'text') { copy.x = originX + (stroke.x - originX) * factor; copy.y = originY + (stroke.y - originY) * factor; copy.fontSize = (stroke.fontSize || 20) * factor; copy.textWidth = textWidthOf(stroke) * factor; } copy.width = (stroke.width || 2) * factor; return copy; } /* ================================================================== * 取原图像素:优先 fetch(blob) → ImageBitmap,退回直接绘制 * ================================================================== */ function loadImageElement(url) { return new Promise(function (resolve, reject) { var image = new Image(); image.onload = function () { resolve(image); }; image.onerror = function () { reject(new Error('image load failed')); }; image.src = url; }); } /** * 取得一个可绘制、且不会污染 canvas 的图源。 * * lightbox 的 src 可能是 blob:/data:/同源 http(s) 或 dsh 自定义协议; * 直接 drawImage() 在跨源时会污染画布导致 toBlob 抛 SecurityError, * 所以先尝试 fetch 成 blob 再解码(fetch 走 CORS,失败可捕获)。 */ function loadSource(img) { var src = img.currentSrc || img.src; var cleanup = []; return fetch(src, { credentials: 'include', cache: 'force-cache' }) .then(function (response) { if (!response.ok) throw new Error('HTTP ' + response.status); return response.blob(); }) .then(function (blob) { if (blob.size === 0) throw new Error('empty blob'); if (typeof createImageBitmap === 'function') { return createImageBitmap(blob, { imageOrientation: 'from-image' }).catch(function () { var url = URL.createObjectURL(blob); cleanup.push(url); return loadImageElement(url); }); } var objectUrl = URL.createObjectURL(blob); cleanup.push(objectUrl); return loadImageElement(objectUrl); }) .catch(function (error) { log('fetch source failed, falling back to ', error); return img; }) .then(function (source) { return { source: source, release: function () { cleanup.forEach(function (url) { try { URL.revokeObjectURL(url); } catch (ignored) { /* 忽略 */ } }); } }; }); } /* ================================================================== * 输入框投递:paste → drop → 剪贴板 → 下载 * ================================================================== */ function composerEditor() { /* 输入框就是 Lexical 的 root:`[data-composer-input]` 与 * `[data-lexical-editor="true"]` 是同一个 div(ui-conversation 在 * setRootElement 之后由 Lexical 打上后者)。优先精确命中前者。 */ var nodes = document.querySelectorAll('[data-composer-input], [data-lexical-editor="true"], [contenteditable="true"]'); var best = null; var bestScore = -1; for (var i = 0; i < nodes.length; i++) { var node = nodes[i]; if (!node.isConnected) continue; var rect = node.getBoundingClientRect(); if (rect.width < 40 || rect.height < 8) continue; var score = 0; if (node.hasAttribute('data-composer-input')) score += 6; if (node.closest('[data-conversation-session]') !== null) score += 2; if (node.getAttribute('data-lexical-editor') === 'true') score += 2; if (node.getAttribute('role') === 'textbox') score += 1; if (score >= bestScore) { bestScore = score; best = node; } } return best; } function countBlobImages() { var count = 0; var images = document.images; for (var i = 0; i < images.length; i++) { var url = String(images[i].currentSrc || images[i].src || ''); if (url.indexOf('blob:') === 0) count++; } return count; } function waitFor(predicate, timeoutMs) { return new Promise(function (resolve) { var started = Date.now(); (function poll() { var value; try { value = predicate(); } catch (error) { value = false; } if (value) { resolve(true); return; } if (Date.now() - started >= timeoutMs) { resolve(false); return; } setTimeout(poll, 120); })(); }); } /** * 造一个「带图片文件的 paste 事件」。 * * composer 的入口读取 `event.clipboardData.items`(kind === 'file'), * 因此先试真正的 ClipboardEvent;某些实现会静默丢弃 clipboardData * init 成员,那就退回 defineProperty 挂一个同形状的假对象。 * 注意:Lexical 会给处理过的事件打 `_lexicalHandled`,所以每次派发 * 都必须新建事件,不能复用。 */ function makePasteEvent(file) { var dataTransfer = new DataTransfer(); dataTransfer.items.add(file); var event = null; try { event = new ClipboardEvent('paste', { clipboardData: dataTransfer, bubbles: true, cancelable: true, composed: true }); if (!event.clipboardData || event.clipboardData.items.length === 0) event = null; } catch (error) { event = null; } if (event === null) { event = new Event('paste', { bubbles: true, cancelable: true, composed: true }); try { Object.defineProperty(event, 'clipboardData', { value: { items: [ { kind: 'file', type: file.type, getAsFile: function () { return file; } } ], files: [file], types: ['Files'], getData: function () { return ''; } } }); } catch (ignored) { /* 忽略:真走到这里只能靠下载兜底 */ } } return event; } /** 用合成 paste 事件把文件交给 composer 的既有图片入口。返回是否命中编辑器。 */ function attachViaPaste(file) { var editor = composerEditor(); if (editor === null) return false; try { editor.focus({ preventScroll: true }); } catch (error) { try { editor.focus(); } catch (ignored) { /* 忽略 */ } } var event = makePasteEvent(file); editor.dispatchEvent(event); log('paste dispatched; defaultPrevented =', event.defaultPrevented); return true; } /** 退回拖放路径:attachment 的 document 级监听接受 drop。 */ function attachViaDrop(file) { var editor = composerEditor(); var host = null; if (editor !== null) { host = editor.closest('form') || editor.parentElement || editor; } if (host === null) host = document; var dataTransfer = new DataTransfer(); dataTransfer.items.add(file); var rect = (editor || host).getBoundingClientRect(); var clientX = rect.left + Math.max(10, Math.min(rect.width / 2, 60)); var clientY = rect.top + Math.max(8, Math.min(rect.height / 2, 20)); var types = ['dragenter', 'dragover', 'drop']; for (var i = 0; i < types.length; i++) { var event = new DragEvent(types[i], { dataTransfer: dataTransfer, bubbles: true, cancelable: true, composed: true, clientX: clientX, clientY: clientY }); host.dispatchEvent(event); } log('drop dispatched'); return true; } function copyBlobToClipboard(blob) { return new Promise(function (resolve) { try { if (!navigator.clipboard || typeof window.ClipboardItem !== 'function') { resolve(false); return; } navigator.clipboard .write([new window.ClipboardItem({ 'image/png': blob })]) .then(function () { resolve(true); }) .catch(function (error) { log('clipboard write failed', error); resolve(false); }); } catch (error) { log('clipboard unavailable', error); resolve(false); } }); } function downloadBlob(blob, filename) { try { var url = URL.createObjectURL(blob); var link = document.createElement('a'); link.href = url; link.download = filename; link.style.display = 'none'; document.body.appendChild(link); link.click(); link.remove(); setTimeout(function () { try { URL.revokeObjectURL(url); } catch (ignored) { /* 忽略 */ } }, 5000); } catch (error) { warn('download failed', error); } } /* ================================================================== * 标注器:一个 lightbox = 一个标注器 * ================================================================== */ function createAnnotator(dialog, img, t) { var root = document.createElement('div'); root.setAttribute('data-dsa-root', ''); dialog.appendChild(root); var canvas = document.createElement('canvas'); canvas.className = 'dsa-canvas'; root.appendChild(canvas); var fab = document.createElement('button'); fab.type = 'button'; fab.className = 'dsa-fab'; fab.title = t('annotateTitle'); fab.setAttribute('aria-label', t('annotateTitle')); fab.appendChild(svgIcon(ICONS.pen)); root.appendChild(fab); var bar = document.createElement('div'); bar.className = 'dsa-bar'; bar.style.display = 'none'; root.appendChild(bar); var toastEl = document.createElement('div'); toastEl.className = 'dsa-toast'; toastEl.style.display = 'none'; root.appendChild(toastEl); var state = { active: false, tool: 'pen', color: PALETTE[0].id, widthIndex: 1, strokes: [], draft: null, rect: null, natural: { w: img.naturalWidth || 0, h: img.naturalHeight || 0 }, busy: false, drawing: false, textInput: null, selected: null, hovered: null, drag: null, past: [], future: [], rafId: 0, timer: 0 }; /* ------------------------------ 尺寸与重绘 ------------------------------ */ function currentWidth() { var base = state.natural.w > 0 ? clamp(state.natural.w * 0.0035, 1.6, 9) : 2.4; if (state.widthIndex === 0) return base * 0.6; if (state.widthIndex === 2) return base * 1.9; return base; } function syncLayout() { var rect = img.getBoundingClientRect(); if (rect.width < 4 || rect.height < 4) return; /* 原图尺寸可能比预览晚到(点开瞬间图还在解码),一旦可用就纠正, * 否则导出会退化成「显示尺寸」。 */ if (img.naturalWidth >= 2 && img.naturalHeight >= 2) { if (state.natural.w !== img.naturalWidth || state.natural.h !== img.naturalHeight) { state.natural = { w: img.naturalWidth, h: img.naturalHeight }; } } else if (state.natural.w < 2 || state.natural.h < 2) { state.natural = { w: Math.round(rect.width), h: Math.round(rect.height) }; } state.rect = rect; var dpr = window.devicePixelRatio || 1; var width = Math.max(1, Math.round(rect.width * dpr)); var height = Math.max(1, Math.round(rect.height * dpr)); if (canvas.width !== width || canvas.height !== height) { canvas.width = width; canvas.height = height; } canvas.style.left = rect.left + 'px'; canvas.style.top = rect.top + 'px'; canvas.style.width = rect.width + 'px'; canvas.style.height = rect.height + 'px'; draw(); } function draw() { var g = canvas.getContext('2d'); g.setTransform(1, 0, 0, 1, 0, 0); g.clearRect(0, 0, canvas.width, canvas.height); if (canvas.width < 2 || state.natural.w < 2) return; var scale = canvas.width / state.natural.w; g.setTransform(scale, 0, 0, scale, 0, 0); for (var i = 0; i < state.strokes.length; i++) paintStroke(g, state.strokes[i]); if (state.draft !== null) paintStroke(g, state.draft); paintChrome(g, scale); } /** 选中框 / 缩放手柄 / 悬停高亮:都画在同一层,缩放天然一致。 */ function paintChrome(g, scale) { var accent = tokenColor({ token: '--dsw-alias-state-business-primary', fallback: '#4176e6' }); var stroke = state.selected; if (stroke !== null && state.strokes.indexOf(stroke) >= 0) { var box = strokeBox(stroke); if (box !== null) { g.save(); g.strokeStyle = accent; g.lineWidth = 1.4 / scale; g.setLineDash([6 / scale, 4 / scale]); g.strokeRect(box.x, box.y, box.w, box.h); g.setLineDash([]); var size = 8 / scale; var handles = handlePoints(box); for (var i = 0; i < handles.length; i++) { g.beginPath(); g.rect(handles[i].x - size / 2, handles[i].y - size / 2, size, size); g.fillStyle = '#ffffff'; g.fill(); g.lineWidth = 1.4 / scale; g.strokeStyle = accent; g.stroke(); } g.restore(); } } else if (state.hovered !== null && state.tool === 'select') { var hoverBox = strokeBox(state.hovered); if (hoverBox !== null) { g.save(); g.strokeStyle = accent; g.globalAlpha = 0.55; g.lineWidth = 1.4 / scale; g.strokeRect(hoverBox.x - 2 / scale, hoverBox.y - 2 / scale, hoverBox.w + 4 / scale, hoverBox.h + 4 / scale); g.restore(); } } } /** 四角手柄(原图像素坐标)。 */ function handlePoints(box) { return [ { x: box.x, y: box.y, corner: 'nw' }, { x: box.x + box.w, y: box.y, corner: 'ne' }, { x: box.x, y: box.y + box.h, corner: 'sw' }, { x: box.x + box.w, y: box.y + box.h, corner: 'se' } ]; } /** 命中哪个手柄(返回角名或 null)。 */ function hitHandle(point) { if (state.selected === null) return null; var box = strokeBox(state.selected); if (box === null) return null; var radius = 10 / currentScale(); var handles = handlePoints(box); for (var i = 0; i < handles.length; i++) { if (Math.abs(point.x - handles[i].x) <= radius && Math.abs(point.y - handles[i].y) <= radius) { return handles[i].corner; } } return null; } function currentScale() { var rect = state.rect || img.getBoundingClientRect(); return Math.max(0.0001, rect.width / Math.max(1, state.natural.w)); } /** 从最上层往下找被点中的那一笔。 */ function pickStroke(point, threshold) { for (var i = state.strokes.length - 1; i >= 0; i--) { if (hitStroke(state.strokes[i], point, threshold)) return state.strokes[i]; } return null; } /** 命中容差:屏幕上约 9px,换算回原图像素。 */ function selectionThreshold() { return Math.max(currentWidth() * 0.9, 9 / Math.max(currentScale(), 0.02)); } function toNatural(event) { var rect = state.rect || img.getBoundingClientRect(); return { x: ((event.clientX - rect.left) / Math.max(1, rect.width)) * state.natural.w, y: ((event.clientY - rect.top) / Math.max(1, rect.height)) * state.natural.h }; } /* ------------------------------ 历史 ------------------------------ */ function snapshot() { return cloneStrokes(state.strokes); } function pushSnapshot(snap) { state.past.push(snap); if (state.past.length > 80) state.past.shift(); state.future.length = 0; refreshButtons(); } function pushHistory() { pushSnapshot(snapshot()); } function undo() { if (state.past.length === 0) return; state.future.push(snapshot()); state.strokes = state.past.pop(); state.selected = null; draw(); refreshButtons(); } function redo() { if (state.future.length === 0) return; state.past.push(snapshot()); state.strokes = state.future.pop(); state.selected = null; draw(); refreshButtons(); } function clearAll() { if (state.strokes.length === 0) return; pushHistory(); state.strokes = []; draw(); } /* ------------------------------ 指针交互 ------------------------------ */ function hitStroke(stroke, point, threshold) { if (stroke.tool === 'text') { var size = stroke.fontSize || 20; var width = textWidthOf(stroke); return ( point.x >= stroke.x - threshold && point.x <= stroke.x + width + threshold && point.y >= stroke.y - threshold && point.y <= stroke.y + size * 1.25 + threshold ); } var points = stroke.points || []; if (stroke.tool === 'rect') { var rectBox = strokeBounds(points); var nearX = Math.min(Math.abs(point.x - rectBox.minX), Math.abs(point.x - rectBox.maxX)); var nearY = Math.min(Math.abs(point.y - rectBox.minY), Math.abs(point.y - rectBox.maxY)); var within = point.x >= rectBox.minX - threshold && point.x <= rectBox.maxX + threshold && point.y >= rectBox.minY - threshold && point.y <= rectBox.maxY + threshold; return within && Math.min(nearX, nearY) <= threshold; } if (stroke.tool === 'ellipse') { var ellipseBox = strokeBounds(points); var rx = ellipseBox.width / 2; var ry = ellipseBox.height / 2; if (rx > 0.5 && ry > 0.5) { var nx = (point.x - (ellipseBox.minX + ellipseBox.maxX) / 2) / rx; var ny = (point.y - (ellipseBox.minY + ellipseBox.maxY) / 2) / ry; return Math.abs(Math.sqrt(nx * nx + ny * ny) - 1) * Math.min(rx, ry) <= threshold; } return false; } if (points.length === 1) { return Math.hypot(point.x - points[0].x, point.y - points[0].y) <= threshold; } for (var i = 1; i < points.length; i++) { if (distanceToSegment(point.x, point.y, points[i - 1].x, points[i - 1].y, points[i].x, points[i].y) <= threshold) { return true; } } return false; } function eraseAt(point) { var threshold = Math.max(currentWidth() * 1.6, 9 / Math.max(currentScale(), 0.02)); for (var i = state.strokes.length - 1; i >= 0; i--) { if (hitStroke(state.strokes[i], point, threshold)) { pushHistory(); if (state.selected === state.strokes[i]) state.selected = null; state.strokes.splice(i, 1); draw(); return true; } } return false; } /* ------------------------------ 选择 / 移动 / 缩放 ------------------------------ */ function selectStroke(stroke) { if (state.selected === stroke) return; state.selected = stroke; draw(); } function deleteSelected() { if (state.selected === null) return false; var index = state.strokes.indexOf(state.selected); if (index < 0) return false; pushHistory(); state.strokes.splice(index, 1); state.selected = null; state.hovered = null; draw(); return true; } function nudgeSelected(dx, dy) { if (state.selected === null) return false; var index = state.strokes.indexOf(state.selected); if (index < 0) return false; pushHistory(); state.strokes[index] = translateStroke(state.selected, dx, dy); state.selected = state.strokes[index]; draw(); return true; } /** 拖动开始:记下原始笔迹,后续每帧都从原始状态重算,避免累积误差。 */ function beginDrag(mode, point, corner) { if (state.selected === null) return false; var box = strokeBox(state.selected); if (box === null) return false; state.drag = { mode: mode, corner: corner || null, start: point, original: cloneStroke(state.selected), box: box, before: snapshot(), historyPushed: false }; return true; } function applyDrag(point) { var drag = state.drag; if (drag === null || state.selected === null) return; var index = state.strokes.indexOf(state.selected); if (index < 0) return; var moved = false; if (drag.mode === 'move') { var dx = point.x - drag.start.x; var dy = point.y - drag.start.y; if (dx === 0 && dy === 0) return; state.strokes[index] = translateStroke(drag.original, dx, dy); moved = true; } else { var originX = drag.corner === 'ne' || drag.corner === 'se' ? drag.box.x : drag.box.x + drag.box.w; var originY = drag.corner === 'sw' || drag.corner === 'se' ? drag.box.y : drag.box.y + drag.box.h; var startDistance = Math.hypot(drag.start.x - originX, drag.start.y - originY); var nowDistance = Math.hypot(point.x - originX, point.y - originY); if (startDistance < 1) return; var factor = clamp(nowDistance / startDistance, 0.15, 12); if (Math.abs(factor - 1) < 0.002) return; state.strokes[index] = scaleStroke(drag.original, factor, originX, originY); moved = true; } if (!moved) return; /* 真正改动过才落一条历史,纯点击不会污染撤销栈 */ if (!drag.historyPushed) { drag.historyPushed = true; pushSnapshot(drag.before); } state.selected = state.strokes[index]; draw(); } function cursorFor(point) { if (state.tool !== 'select') return null; var corner = hitHandle(point); if (corner === 'nw' || corner === 'se') return 'nwse-resize'; if (corner === 'ne' || corner === 'sw') return 'nesw-resize'; var hovered = pickStroke(point, selectionThreshold()); state.hovered = hovered; return hovered !== null ? 'move' : 'default'; } function onPointerDown(event) { if (!state.active || state.busy) return; if (event.button !== undefined && event.button !== 0) return; var point = toNatural(event); if (state.tool === 'text') { event.preventDefault(); var existing = pickStroke(point, selectionThreshold()); if (existing !== null && existing.tool === 'text') { /* 文字工具里直接拖已有的文字框:不用切工具就能挪位置 */ selectStroke(existing); beginDrag('move', point, null); state.drawing = true; try { canvas.setPointerCapture(event.pointerId); } catch (ignored) { /* 忽略 */ } return; } openTextEditor(event); return; } event.preventDefault(); try { canvas.setPointerCapture(event.pointerId); } catch (ignored) { /* 忽略 */ } if (state.tool === 'select') { var corner = hitHandle(point); if (corner !== null) { state.drawing = true; beginDrag('resize', point, corner); return; } var hit = pickStroke(point, selectionThreshold()); if (hit !== null) { selectStroke(hit); state.drawing = true; beginDrag('move', point, null); return; } state.selected = null; state.hovered = null; draw(); refreshButtons(); return; } state.drawing = true; if (state.tool === 'eraser') { eraseAt(point); return; } state.draft = { tool: state.tool, color: tokenColor(paletteEntry(state.color)), width: currentWidth(), points: [point, { x: point.x, y: point.y }] }; draw(); } var lastCursor = ''; function setCursor(value) { if (value === lastCursor) return; lastCursor = value; canvas.style.cursor = value; } function onPointerMove(event) { var point = toNatural(event); if (!state.drawing) { var cursor = cursorFor(point); setCursor(cursor === null ? (state.tool === 'select' ? 'default' : 'crosshair') : cursor); if (state.tool === 'select' && state.hovered !== null && state.selected === null) draw(); return; } if (state.drag !== null) { applyDrag(point); return; } if (state.tool === 'eraser') { eraseAt(point); return; } if (state.draft === null) return; if (state.tool === 'pen') { var last = state.draft.points[state.draft.points.length - 1]; if (Math.hypot(point.x - last.x, point.y - last.y) < state.natural.w * 0.0012) return; state.draft.points.push(point); } else if (state.tool === 'arrow') { state.draft.points[1] = point; } else { /* 方框 / 圆圈:累积轨迹,落笔时按外接框成形 */ state.draft.points.push(point); } draw(); } function onPointerUp(event) { if (!state.drawing) return; state.drawing = false; try { canvas.releasePointerCapture(event.pointerId); } catch (ignored) { /* 忽略 */ } if (state.drag !== null) { state.drag = null; draw(); return; } if (state.draft !== null) { var draft = state.draft; state.draft = null; var box = strokeBounds(draft.points); if (draft.tool === 'pen' ? draft.points.length > 0 : box.width > 2 || box.height > 2) { pushHistory(); state.strokes.push(draft); /* 刚画完就选中,方便马上调位置 */ state.selected = draft; } draw(); } } function onDoubleClick(event) { if (!state.active) return; var point = toNatural(event); var hit = pickStroke(point, selectionThreshold()); if (hit !== null && hit.tool === 'text') { event.preventDefault(); selectStroke(hit); openTextEditor(event, hit); } } canvas.addEventListener('pointerdown', onPointerDown); canvas.addEventListener('pointermove', onPointerMove); canvas.addEventListener('pointerup', onPointerUp); canvas.addEventListener('pointercancel', onPointerUp); canvas.addEventListener('dblclick', onDoubleClick); /* ------------------------------ 文字工具 ------------------------------ */ function closeTextEditor() { if (state.textInput !== null) { var input = state.textInput; state.textInput = null; input.remove(); } } /** * 打开文字输入框。 * * @param event 触发事件(用来定输入框位置与落点)。 * @param existing 传入已有文字笔迹时是「改字」,否则是新建。 */ function openTextEditor(event, existing) { closeTextEditor(); var point = toNatural(event); var isEdit = existing !== undefined && existing !== null; var input = document.createElement('input'); input.className = 'dsa-textinput'; input.placeholder = t('textPlaceholder'); if (isEdit) input.value = existing.text || ''; input.style.left = clamp(event.clientX - 40, 8, Math.max(8, window.innerWidth - 240)) + 'px'; input.style.top = clamp(event.clientY - 16, 8, Math.max(8, window.innerHeight - 52)) + 'px'; root.appendChild(input); state.textInput = input; var settled = false; function finish(commit) { if (settled) return; settled = true; var text = input.value.trim(); closeTextEditor(); if (!commit || text.length === 0) { draw(); return; } if (isEdit) { var index = state.strokes.indexOf(existing); if (index < 0) return; pushHistory(); var updated = cloneStroke(state.strokes[index]); updated.text = text; updated.textWidth = measureTextWidth(text, updated.fontSize || 20); state.strokes[index] = updated; state.selected = updated; draw(); return; } var fontSize = currentWidth() * 7; pushHistory(); var stroke = { tool: 'text', color: tokenColor(paletteEntry(state.color)), width: currentWidth(), fontSize: fontSize, textWidth: measureTextWidth(text, fontSize), x: point.x, y: point.y, text: text }; state.strokes.push(stroke); state.selected = stroke; draw(); } input.addEventListener('keydown', function (keyEvent) { keyEvent.stopPropagation(); if (keyEvent.key === 'Enter') { keyEvent.preventDefault(); finish(true); } else if (keyEvent.key === 'Escape') { keyEvent.preventDefault(); finish(false); } }); input.addEventListener('blur', function () { finish(true); }); try { input.focus({ preventScroll: true }); } catch (ignored) { input.focus(); } } /* ------------------------------ 工具栏 ------------------------------ */ var toolButtons = {}; var swatchButtons = []; var widthButtons = []; var undoButton = null; var redoButton = null; var trashButton = null; var primaryButton = null; function refreshButtons() { for (var id in toolButtons) { if (Object.prototype.hasOwnProperty.call(toolButtons, id)) { toolButtons[id].setAttribute('data-dsa-on', state.tool === id ? '1' : '0'); } } swatchButtons.forEach(function (entry) { entry.el.setAttribute('data-dsa-on', entry.id === state.color ? '1' : '0'); }); widthButtons.forEach(function (entry, index) { entry.el.setAttribute('data-dsa-on', index === state.widthIndex ? '1' : '0'); }); if (undoButton !== null) undoButton.disabled = state.past.length === 0; if (redoButton !== null) redoButton.disabled = state.future.length === 0; if (trashButton !== null) { var hasSelection = state.selected !== null && state.strokes.indexOf(state.selected) >= 0; var label = hasSelection ? t('deleteSelected') : t('clear'); trashButton.title = label; trashButton.setAttribute('aria-label', label); trashButton.disabled = hasSelection ? false : state.strokes.length === 0; } } function buildBar() { TOOLS.forEach(function (tool) { var button = iconButton(tool.icon, t(tool.id)); button.dataset.dsaTool = tool.id; button.addEventListener('click', function () { state.tool = tool.id; if (tool.id !== 'select') state.hovered = null; closeTextEditor(); refreshButtons(); setCursor(tool.id === 'select' ? 'default' : 'crosshair'); draw(); }); toolButtons[tool.id] = button; bar.appendChild(button); }); var sep1 = document.createElement('span'); sep1.className = 'dsa-sep'; bar.appendChild(sep1); PALETTE.forEach(function (color) { var swatch = document.createElement('button'); swatch.type = 'button'; swatch.className = 'dsa-swatch'; swatch.dataset.dsaColor = color.id; swatch.style.background = 'var(' + color.token + ',' + color.fallback + ')'; swatch.title = t('color_' + color.id); swatch.setAttribute('aria-label', t('color_' + color.id)); swatch.addEventListener('click', function () { state.color = color.id; /* 已有选中文字时换色,直接改它,符合「选中即编辑」的直觉 */ var index = state.selected === null ? -1 : state.strokes.indexOf(state.selected); if (index >= 0 && state.selected.tool === 'text') { pushHistory(); var updated = cloneStroke(state.strokes[index]); updated.color = tokenColor(color); state.strokes[index] = updated; state.selected = updated; draw(); } refreshButtons(); }); swatchButtons.push({ el: swatch, id: color.id }); bar.appendChild(swatch); }); var sep2 = document.createElement('span'); sep2.className = 'dsa-sep'; bar.appendChild(sep2); [3, 5.4, 9].forEach(function (size, index) { var button = document.createElement('button'); button.type = 'button'; button.className = 'dsa-width'; button.dataset.dsaWidth = String(index); button.title = index === 0 ? '细' : index === 1 ? '中' : '粗'; var dot = document.createElement('span'); dot.style.width = size + 'px'; dot.style.height = size + 'px'; button.appendChild(dot); button.addEventListener('click', function () { state.widthIndex = index; refreshButtons(); }); widthButtons.push({ el: button }); bar.appendChild(button); }); var sep3 = document.createElement('span'); sep3.className = 'dsa-sep'; bar.appendChild(sep3); undoButton = iconButton(ICONS.undo, t('undo')); undoButton.dataset.dsaAction = 'undo'; undoButton.addEventListener('click', undo); bar.appendChild(undoButton); redoButton = iconButton(ICONS.redo, t('redo')); redoButton.dataset.dsaAction = 'redo'; redoButton.addEventListener('click', redo); bar.appendChild(redoButton); trashButton = iconButton(ICONS.trash, t('clear')); trashButton.dataset.dsaAction = 'clear'; trashButton.addEventListener('click', function () { if (state.selected !== null && state.strokes.indexOf(state.selected) >= 0) deleteSelected(); else clearAll(); }); bar.appendChild(trashButton); var sep4 = document.createElement('span'); sep4.className = 'dsa-sep'; bar.appendChild(sep4); primaryButton = document.createElement('button'); primaryButton.type = 'button'; primaryButton.className = 'dsa-primary'; primaryButton.title = t('attachTitle'); primaryButton.dataset.dsaAction = 'attach'; primaryButton.appendChild(svgIcon(ICONS.check)); var primaryLabel = document.createElement('span'); primaryLabel.textContent = t('attach'); primaryButton.appendChild(primaryLabel); primaryButton.addEventListener('click', function () { commit('attach'); }); bar.appendChild(primaryButton); var downloadButton = iconButton(ICONS.download, t('download')); downloadButton.dataset.dsaAction = 'download'; downloadButton.addEventListener('click', function () { commit('download'); }); bar.appendChild(downloadButton); var exitButton = iconButton(ICONS.close, t('exit')); exitButton.dataset.dsaAction = 'exit'; exitButton.addEventListener('click', function () { setActive(false); }); bar.appendChild(exitButton); } buildBar(); refreshButtons(); /* ------------------------------ 状态切换 ------------------------------ */ function setActive(active) { state.active = active; canvas.setAttribute('data-dsa-active', active ? '1' : '0'); bar.style.display = active ? 'flex' : 'none'; /* fab 用 grid 布局(原生圆形按钮),隐藏要用 none */ fab.style.display = active ? 'none' : 'grid'; if (!active) { closeTextEditor(); state.selected = null; state.hovered = null; state.drag = null; canvas.style.cursor = ''; } if (active) { canvas.style.cursor = state.tool === 'select' ? 'default' : 'crosshair'; syncLayout(); /* 预览可能有入场动画,短时间内跟随几帧 */ var frames = 0; (function follow() { if (!state.active || frames++ > 40) return; syncLayout(); state.rafId = requestAnimationFrame(follow); })(); } } fab.addEventListener('click', function () { setActive(true); }); /* ------------------------------ 提示 ------------------------------ */ function toast(message, duration) { toastEl.textContent = message; toastEl.style.display = 'block'; if (state.timer) clearTimeout(state.timer); state.timer = setTimeout(function () { toastEl.style.display = 'none'; }, duration || 3200); } /* ------------------------------ 导出与投递 ------------------------------ */ function exportBlob() { return loadSource(img).then(function (loaded) { var width = state.natural.w || img.naturalWidth || 0; var height = state.natural.h || img.naturalHeight || 0; if (width < 2 || height < 2) throw new Error('image size unknown'); var target = document.createElement('canvas'); target.width = width; target.height = height; var g = target.getContext('2d'); g.drawImage(loaded.source, 0, 0, width, height); for (var i = 0; i < state.strokes.length; i++) paintStroke(g, state.strokes[i]); loaded.release(); return new Promise(function (resolve, reject) { target.toBlob(function (blob) { if (blob === null) reject(new Error('toBlob returned null')); else resolve(blob); }, 'image/png'); }); }); } function setBusy(busy) { state.busy = busy; if (primaryButton !== null) { primaryButton.setAttribute('data-dsa-busy', busy ? '1' : '0'); primaryButton.disabled = busy; } } function closeLightbox() { var close = dialog.querySelector(':scope > button'); if (close !== null) { close.click(); return; } window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); } function commit(kind) { if (state.busy) return; if (kind === 'attach' && state.strokes.length === 0) { toast(t('empty')); return; } setBusy(true); var filename = 'annotated-' + stamp() + '.png'; toast(t('working'), 60000); exportBlob() .then(function (blob) { if (kind === 'download') { downloadBlob(blob, filename); toast(t('downloaded')); return null; } var file = new File([blob], filename, { type: 'image/png' }); var before = countBlobImages(); var pasted = attachViaPaste(file); if (!pasted) { toast(t('noEditor')); return null; } return waitFor(function () { return countBlobImages() > before; }, 2200).then(function (appeared) { if (appeared) { log('attachment confirmed'); toast(t('attached')); closeLightbox(); return null; } log('attachment not confirmed; trying drop'); attachViaDrop(file); return waitFor(function () { return countBlobImages() > before; }, 1200).then(function (dropped) { if (dropped) { toast(t('attached')); closeLightbox(); return null; } return copyBlobToClipboard(blob).then(function (copied) { if (copied) { toast(t('uncertain'), 6000); closeLightbox(); } else { downloadBlob(blob, filename); toast(t('downloaded'), 5000); } return null; }); }); }); }) .catch(function (error) { warn('export failed', error); toast(t('exportFailed') + (error && error.message ? ':' + error.message : ''), 5000); }) .then(function () { setBusy(false); }); } /* ------------------------------ 键盘快捷键 ------------------------------ */ function onKeyDown(event) { if (!state.active) return; /* 正在输入文字时,键盘归输入框 */ if (state.textInput !== null) return; var modifier = event.ctrlKey || event.metaKey; if (modifier && (event.key === 'z' || event.key === 'Z')) { event.preventDefault(); event.stopPropagation(); if (event.shiftKey) redo(); else undo(); return; } if (modifier && (event.key === 'y' || event.key === 'Y')) { event.preventDefault(); event.stopPropagation(); redo(); return; } if (modifier || event.altKey) return; if (event.key === 'Delete' || event.key === 'Backspace') { if (deleteSelected()) { event.preventDefault(); return; } } if (state.selected !== null && event.key.indexOf('Arrow') === 0) { var step = event.shiftKey ? 10 : 1; var dx = event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0; var dy = event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0; if (nudgeSelected(dx, dy)) { event.preventDefault(); return; } } if (event.key === 'v' || event.key === 'V') { event.preventDefault(); state.tool = 'select'; refreshButtons(); return; } var order = ['select', 'pen', 'arrow', 'rect', 'ellipse', 'text', 'eraser']; var next = -1; if (event.key >= '1' && event.key <= '7') next = Number(event.key) - 1; else if (event.key === 'e' || event.key === 'E') next = state.tool === 'eraser' ? 1 : 6; if (next >= 0 && next < order.length) { event.preventDefault(); state.tool = order[next]; if (state.tool !== 'select') state.hovered = null; closeTextEditor(); refreshButtons(); draw(); } } window.addEventListener('keydown', onKeyDown, true); /* ------------------------------ 位置同步与生命周期 ------------------------------ */ var resizeObserver = null; if (typeof ResizeObserver === 'function') { resizeObserver = new ResizeObserver(function () { syncLayout(); }); try { resizeObserver.observe(img); } catch (ignored) { /* 忽略 */ } } window.addEventListener('resize', syncLayout); img.addEventListener('load', syncLayout); if (!img.complete) { img.addEventListener('load', syncLayout, { once: true }); } syncLayout(); setTimeout(syncLayout, 60); setTimeout(syncLayout, 240); function destroy() { window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('resize', syncLayout); img.removeEventListener('load', syncLayout); if (resizeObserver !== null) resizeObserver.disconnect(); if (state.rafId) cancelAnimationFrame(state.rafId); if (state.timer) clearTimeout(state.timer); closeTextEditor(); root.remove(); var registry = window.__dshImageAnnotate; if (registry) { var index = registry.annotators.indexOf(handle); if (index >= 0) registry.annotators.splice(index, 1); } } var handle = { dialog: dialog, state: state, setActive: setActive, destroy: destroy }; var registry = window.__dshImageAnnotate; if (registry) registry.annotators.push(handle); return handle; } /* ================================================================== * lightbox 观察器 * ================================================================== */ function isLightbox(dialog) { /* 结构(dsh-client-ui-primitives ImageLightbox): * body portal > div[role=dialog][aria-modal=true] * > div[aria-hidden=true](遮罩), img(原图), button(关闭) * 这里只依赖「dialog 直接子元素里有 img」这个稳定特征。 */ if (dialog.getAttribute('role') !== 'dialog') return false; if (dialog.getAttribute('aria-modal') !== 'true') return false; var img = dialog.querySelector(':scope > img'); return img !== null; } function startWatcher(t) { var annotators = []; var known = new WeakSet(); var scheduled = false; var lastRun = 0; function reconcile() { scheduled = false; lastRun = Date.now(); for (var i = annotators.length - 1; i >= 0; i--) { if (!annotators[i].dialog.isConnected) { try { annotators[i].destroy(); } catch (error) { warn('destroy failed', error); } annotators.splice(i, 1); } } var dialogs = document.body.querySelectorAll(':scope > div[role="dialog"][aria-modal="true"]'); for (var j = 0; j < dialogs.length; j++) { var dialog = dialogs[j]; if (known.has(dialog)) continue; if (!isLightbox(dialog)) continue; var img = dialog.querySelector(':scope > img'); known.add(dialog); try { annotators.push(createAnnotator(dialog, img, t)); log('annotator attached'); } catch (error) { warn('attach failed', error); } } } function schedule() { if (scheduled) return; var elapsed = Date.now() - lastRun; if (elapsed > 220) { reconcile(); return; } scheduled = true; setTimeout(function () { if (scheduled) reconcile(); }, 220 - elapsed); } var observer = new MutationObserver(schedule); observer.observe(document.body, { childList: true, subtree: true }); reconcile(); return function stop() { observer.disconnect(); annotators.forEach(function (annotator) { try { annotator.destroy(); } catch (error) { warn('destroy failed', error); } }); annotators.length = 0; }; } /* ================================================================== * 插件入口 * ================================================================== */ function apply(ctx) { if (typeof window === 'undefined' || typeof document === 'undefined') return; var t = makeTranslator(ctx); injectStyles(); /* 诊断入口:控制台里 `__dshImageAnnotate.annotators[0].state` 可看实时状态 */ if (!window.__dshImageAnnotate) { window.__dshImageAnnotate = { version: '1.0.0', package: PKG_ID, annotators: [] }; } var stop = startWatcher(t); if (ctx && typeof ctx.effect === 'function') { ctx.effect(function () { return stop; }); } else { window.addEventListener('beforeunload', stop); } log('ready (v1.0.0)'); } exports.apply = apply; exports.inject = []; return module.exports; } }); //# sourceMappingURL=client.js.map