dsh-figmark 1.2.2 → 1.2.3

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/CHANGELOG.md CHANGED
@@ -1,5 +1,24 @@
1
1
  # Changelog
2
2
 
3
+ ## 1.2.3 — 2026-11-02
4
+
5
+ 本版增加输入框附图标注,并修复导出 SVG 与画布 Pin 编号的显示问题。
6
+
7
+ ### Added
8
+
9
+ - 直接点击输入框里的图片缩略图即可打开原图标注浮层,支持画笔、箭头、矩形、椭圆、Pin、颜色、撤销/重做与缩放。
10
+ - 「加入输入框」生成独立的 `*.annotated.png`,并把同编号的逐条评论写入现有草稿;原图不变,也不会自动发送。
11
+
12
+ ### Improved
13
+
14
+ - 标注浮层去掉整块外框,工具条悬浮在图片右侧;底部只保留「下载 PNG」和「加入输入框」两个小按钮。
15
+ - 评论输入沿用原版单胶囊样式,不再显示标题栏、说明行或额外按钮。
16
+
17
+ ### Fixed
18
+
19
+ - 修复活动画布中 Pin 编号被 SVG 继承描边染黑的问题;预览与导出现在都是白色数字。
20
+ - 修复 Visio SVG 导出时丢失根命名空间和继承样式的问题。
21
+
3
22
  ## 1.2.2 — 2026-11-01
4
23
 
5
24
  本版集中完善「本机网页」批注、批注卡片导航和公开展示文档。
package/README.md CHANGED
@@ -51,6 +51,14 @@ dsh-figmark 把三样东西一起交给智能体:
51
51
  | 本机网页 | `http://localhost` / `127.0.0.1` / `[::1]` | ✅ 在右侧栏「本机网页」里发现并打开;真实脚本、路由、媒体与元素锚定可用 |
52
52
  | Office | `doc` `docx` `ppt` `pptx` `xls` `xlsx` | ⏳ 待接入,仍由内置预览器打开 |
53
53
 
54
+ ## 输入框附图的独立批注(测试功能)
55
+
56
+ 添加图片后,直接点击输入框里的原生缩略图即可在原图上标注;工具条悬浮在图片右侧,支持画笔、箭头、矩形、椭圆和 Pin 评论,以及移动、缩放标记、改色、撤销/重做。
57
+
58
+ 「加入输入框」会生成标注 PNG,并把逐条评论追加到现有草稿一起发送;原图保持不变,已有文字保留,之后由你确认附件和评论并按宿主的发送按钮。编辑、取消和下载均不调用智能体,不建立批注队列,也不写侧车文件。
59
+
60
+ 当前保留一张图的临时批注,刷新或明确清除后不保留可编辑图层;导出的 PNG 为静态副本。完整步骤、宿主兼容性与待实测项目见 [附图标注测试说明](docs/attachment-annotator-testing.md)。
61
+
54
62
  ## 安装
55
63
 
56
64
  ### 从插件市场安装(推荐)
package/lib/client.js CHANGED
@@ -3808,6 +3808,7 @@ window.__ModuleLoader__.load({
3808
3808
  fontSize: pinFontSizeOf(unit),
3809
3809
  fontWeight: 700,
3810
3810
  fill: PIN_NUMBER_COLOR,
3811
+ stroke: "none",
3811
3812
  textAnchor: "middle",
3812
3813
  dominantBaseline: "central",
3813
3814
  children: number
@@ -4026,6 +4027,7 @@ window.__ModuleLoader__.load({
4026
4027
  fontSize,
4027
4028
  fontWeight: 600,
4028
4029
  fill: CODE_TEXT_COLOR,
4030
+ stroke: "none",
4029
4031
  dominantBaseline: "central",
4030
4032
  children: code
4031
4033
  })] });
@@ -5498,9 +5500,15 @@ window.__ModuleLoader__.load({
5498
5500
  */
5499
5501
  function figureSvg(layer, lit = []) {
5500
5502
  if (layer.kind === "raster") return `<image x="0" y="0" width="${layer.width}" height="${layer.height}" href="${layer.dataUrl}"/>`;
5501
- const viewBox = layer.viewBox === void 0 ? "" : ` viewBox="${layer.viewBox}"`;
5502
- const inner = lightMarkup(layer.markup, lit).replace(/^<\?xml[^>]*\?>\s*/u, "").replace(/^<svg[^>]*>/iu, "").replace(/<\/svg>\s*$/iu, "");
5503
- return `<svg x="0" y="0" width="${layer.width}" height="${layer.height}"${viewBox} preserveAspectRatio="xMidYMid meet">${inner}</svg>`;
5503
+ const litMarkup = lightMarkup(layer.markup, lit);
5504
+ const root = new DOMParser().parseFromString(litMarkup, "image/svg+xml").documentElement;
5505
+ root.setAttribute("x", "0");
5506
+ root.setAttribute("y", "0");
5507
+ root.setAttribute("width", String(layer.width));
5508
+ root.setAttribute("height", String(layer.height));
5509
+ if (layer.viewBox !== void 0) root.setAttribute("viewBox", layer.viewBox);
5510
+ root.setAttribute("preserveAspectRatio", "xMidYMid meet");
5511
+ return new XMLSerializer().serializeToString(root);
5504
5512
  }
5505
5513
  /**
5506
5514
  * Encode bytes as a `data:` URL.
@@ -9456,6 +9464,46 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
9456
9464
  ]
9457
9465
  });
9458
9466
  }
9467
+ /** Put one more copy beside the existing sheet. */
9468
+ function CopyIcon() {
9469
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
9470
+ className: "da-ico",
9471
+ viewBox: "0 0 24 24",
9472
+ "aria-hidden": "true",
9473
+ focusable: "false",
9474
+ children: [
9475
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
9476
+ x: "8",
9477
+ y: "8",
9478
+ width: "11.5",
9479
+ height: "11.5",
9480
+ rx: "2.2"
9481
+ }),
9482
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M16 8V6.5a2.2 2.2 0 0 0-2.2-2.2H6.7a2.2 2.2 0 0 0-2.2 2.2v7.1A2.2 2.2 0 0 0 6.7 15.8H8" }),
9483
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M13.75 11.3v5M11.25 13.8h5" })
9484
+ ]
9485
+ });
9486
+ }
9487
+ /** Save a file out of the editor. */
9488
+ function DownloadIcon() {
9489
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
9490
+ className: "da-ico",
9491
+ viewBox: "0 0 24 24",
9492
+ "aria-hidden": "true",
9493
+ focusable: "false",
9494
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M12 3.5v11M7.8 10.5 12 14.7l4.2-4.2" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M4.5 16.5v2a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2v-2" })]
9495
+ });
9496
+ }
9497
+ /** Dismiss the editor without suggesting that the draft should be saved. */
9498
+ function CloseIcon() {
9499
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
9500
+ className: "da-ico",
9501
+ viewBox: "0 0 24 24",
9502
+ "aria-hidden": "true",
9503
+ focusable: "false",
9504
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M6.5 6.5l11 11M17.5 6.5l-11 11" })
9505
+ });
9506
+ }
9459
9507
  /** A step back, as the usual curved arrow. */
9460
9508
  function UndoIcon() {
9461
9509
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
@@ -9539,7 +9587,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
9539
9587
  */
9540
9588
  const travelHeight = (fraction) => `calc(var(--da-track-inset) + var(--da-knob-size) / 2 + (100% - 2 * var(--da-track-inset) - var(--da-knob-size)) * ${fraction})`;
9541
9589
  /** Tools in toolbar order. */
9542
- const TOOLS = [
9590
+ const TOOLS$1 = [
9543
9591
  {
9544
9592
  tool: "select",
9545
9593
  label: "toolSelect"
@@ -15079,7 +15127,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15079
15127
  stripRef: paletteRef,
15080
15128
  style: paletteStyle,
15081
15129
  handle: paletteHandle,
15082
- tools: isVector || isHtml ? TOOLS : TOOLS.filter((entry) => !ELEMENT_TOOLS.has(entry.tool)),
15130
+ tools: isVector || isHtml ? TOOLS$1 : TOOLS$1.filter((entry) => !ELEMENT_TOOLS.has(entry.tool)),
15083
15131
  tool,
15084
15132
  elementGuide: tool === "element" ? {
15085
15133
  label: pickLevel === void 0 ? `${t("toolElementMode")}, ${t("toolElementHint")}, ${t("toolElementKeys")}` : `${t("toolElementMode")}, ${t("toolElementDepth", {
@@ -17278,6 +17326,1057 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
17278
17326
  });
17279
17327
  }
17280
17328
  //#endregion
17329
+ //#region src/client/attachment-annotator/model.ts
17330
+ /** One explicitly opened image at a time; retained across sidebar remounts. */
17331
+ function createAttachmentStore() {
17332
+ let job = null;
17333
+ const listeners = /* @__PURE__ */ new Set();
17334
+ const publish = () => {
17335
+ for (const listener of listeners) listener();
17336
+ };
17337
+ return {
17338
+ getSnapshot: () => job,
17339
+ subscribe(listener) {
17340
+ listeners.add(listener);
17341
+ return () => {
17342
+ listeners.delete(listener);
17343
+ };
17344
+ },
17345
+ /** Refuse to overwrite an unfinished image, including one in another Session. */
17346
+ open(sessionId, source) {
17347
+ if (job !== null) return job.sessionId === sessionId && job.source.id === source.id;
17348
+ job = {
17349
+ sessionId,
17350
+ source,
17351
+ marks: [],
17352
+ added: null
17353
+ };
17354
+ publish();
17355
+ return true;
17356
+ },
17357
+ change(source, marks) {
17358
+ if (job === null || job.source !== source || job.marks === marks) return;
17359
+ job = {
17360
+ ...job,
17361
+ marks
17362
+ };
17363
+ publish();
17364
+ },
17365
+ rememberCopy(source, marks, id) {
17366
+ if (job === null || job.source !== source) return;
17367
+ job = {
17368
+ ...job,
17369
+ added: {
17370
+ marks,
17371
+ id
17372
+ }
17373
+ };
17374
+ publish();
17375
+ },
17376
+ clear() {
17377
+ job = null;
17378
+ publish();
17379
+ },
17380
+ dispose() {
17381
+ job = null;
17382
+ listeners.clear();
17383
+ }
17384
+ };
17385
+ }
17386
+ /** Submission phases never accept a result; an attachment sent or removed is never resurrected. */
17387
+ function canAttach(job, sessionId, input) {
17388
+ return sessionId === job.sessionId && input.attachmentIds.includes(job.source.id) && (input.phase === "plain" || input.phase === "claimed");
17389
+ }
17390
+ /**
17391
+ * The text that keeps each numbered comment searchable after the flattened image
17392
+ * is staged. The image and the list carry the same ordinals, so the model can
17393
+ * connect a sentence to the mark it describes without relying on OCR.
17394
+ */
17395
+ function attachmentMessage(fileName, marks) {
17396
+ const counted = /* @__PURE__ */ new Map();
17397
+ const lines = marks.map((mark) => {
17398
+ const ordinal = (counted.get(mark.kind) ?? 0) + 1;
17399
+ counted.set(mark.kind, ordinal);
17400
+ return describeMark(mark, ordinal, "zh");
17401
+ });
17402
+ return [
17403
+ `【附图批注】已附上「${fileName.trim() || "图片"}」的标注副本,共 ${marks.length} 处。请按编号逐条处理:`,
17404
+ ...lines,
17405
+ "",
17406
+ "逐条修改原图的生成源。"
17407
+ ].join("\n");
17408
+ }
17409
+ /** Stage one copy and its numbered comments without replacing the original or sending. */
17410
+ function attachCopy(job, sessionId, input, conversation, actions, file) {
17411
+ if (!canAttach(job, sessionId, input)) throw new Error("原附件已移除、已发送,或输入框正在发送。请下载批注副本。");
17412
+ if (!conversation.resolveDraftAttachments([job.source.id]).some((draft) => draft.id === job.source.id && draft.kind === "image" && "file" in draft && draft.file === job.source.file)) throw new Error("原附件已失效,请下载批注副本。");
17413
+ const ids = conversation.createDrafts(sessionId, [file], false).map((draft) => draft.id);
17414
+ let attached = false;
17415
+ try {
17416
+ if (ids.length !== 1 || !actions.addAttachments(ids)) throw new Error("输入框未接受副本;原附件保持不变。");
17417
+ attached = true;
17418
+ const message = attachmentMessage(job.source.file.name, job.marks);
17419
+ actions.setDraft(input.draft.trim() === "" ? message : `${input.draft}\n\n${message}`);
17420
+ return ids[0];
17421
+ } catch (error) {
17422
+ if (attached) try {
17423
+ actions.setDraft(input.draft);
17424
+ } catch {}
17425
+ for (const id of ids) conversation.releaseDraftAttachment(id);
17426
+ throw error;
17427
+ }
17428
+ }
17429
+ /** Older hosts can keep all existing Figmark features without crashing this optional entry. */
17430
+ function attachmentConversation(value) {
17431
+ if (typeof value !== "object" || value === null) return void 0;
17432
+ const candidate = value;
17433
+ return typeof candidate.resolveDraftAttachments === "function" && typeof candidate.createDrafts === "function" && typeof candidate.releaseDraftAttachment === "function" ? candidate : void 0;
17434
+ }
17435
+ //#endregion
17436
+ //#region src/client/attachment-annotator/export.ts
17437
+ /** Plain XML text; comments must never become SVG markup. */
17438
+ function escapeText(value) {
17439
+ return value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
17440
+ }
17441
+ /** Build one self-contained SVG with a readable legend below the original image. */
17442
+ function attachmentSvg(dataUrl, size, marks) {
17443
+ const layer = {
17444
+ kind: "raster",
17445
+ dataUrl,
17446
+ ...size
17447
+ };
17448
+ const paper = reviewSize(layer, marks);
17449
+ const numbers = markOrdinals(marks);
17450
+ const font = Math.max(14, Math.min(32, paper.width / 45));
17451
+ const width = Math.max(paper.width, 320);
17452
+ const columns = Math.max(10, Math.floor((width - 32) / font));
17453
+ const lines = marks.flatMap((mark) => {
17454
+ if (mark.detail === void 0 || mark.detail.trim() === "") return [];
17455
+ return `${mark.kind === "point" ? "Pin" : mark.kind} ${numbers.get(mark.id)} · ${mark.detail}`.split(/\r?\n/u).flatMap((line) => {
17456
+ const chars = Array.from(line);
17457
+ const wrapped = [];
17458
+ for (let at = 0; at < chars.length; at += columns) wrapped.push(chars.slice(at, at + columns).join(""));
17459
+ return wrapped;
17460
+ });
17461
+ });
17462
+ const height = paper.height + (lines.length === 0 ? 0 : 32 + lines.length * font * 1.5);
17463
+ return {
17464
+ svg: `<svg xmlns="http://www.w3.org/2000/svg" width="${width}" height="${height}" viewBox="0 0 ${width} ${height}"><rect width="100%" height="100%" fill="white"/>${composeReviewSvg(layer, marks)}${lines.map((line, at) => `<text x="16" y="${paper.height + 24 + at * font * 1.5}" font-size="${font}" fill="#222" font-family="sans-serif">${escapeText(line)}</text>`).join("")}</svg>`,
17465
+ width,
17466
+ height,
17467
+ scale: Math.min(1, 8192 / width, 8192 / height, Math.sqrt(16e6 / (width * height)))
17468
+ };
17469
+ }
17470
+ /** Read only when exporting; the host's preview URL remains owned by the host. */
17471
+ async function exportAttachment(file, size, marks) {
17472
+ const image = attachmentSvg(await new Promise((resolve, reject) => {
17473
+ const reader = new FileReader();
17474
+ reader.onload = () => {
17475
+ resolve(reader.result);
17476
+ };
17477
+ reader.onerror = () => {
17478
+ reject(/* @__PURE__ */ new Error("无法读取原图,请重试。"));
17479
+ };
17480
+ reader.readAsDataURL(file);
17481
+ }), size, marks);
17482
+ const blob = await rasterizePng(image.svg, image.width, image.height, image.scale);
17483
+ return new File([blob], `${file.name.replace(/\.[^.]*$/u, "") || "image"}.annotated.png`, { type: "image/png" });
17484
+ }
17485
+ /** Download is explicit and does not involve any Session. */
17486
+ function downloadAttachment(file) {
17487
+ const url = URL.createObjectURL(file);
17488
+ const link = document.createElement("a");
17489
+ link.href = url;
17490
+ link.download = file.name;
17491
+ link.click();
17492
+ setTimeout(() => {
17493
+ URL.revokeObjectURL(url);
17494
+ }, 1e3);
17495
+ }
17496
+ //#endregion
17497
+ //#region src/client/attachment-annotator/styles.ts
17498
+ /** Styles for the composer-thumbnail annotation overlay. */
17499
+ const ATTACHMENT_CSS = `
17500
+ .da-attachment-anchor{display:block;width:0;height:0;overflow:hidden;pointer-events:none}
17501
+ .da-attachment{
17502
+ --da-accent:#e03131;
17503
+ --da-blue:#176df2;
17504
+ --da-ink:#242529;
17505
+ --da-muted:#74777f;
17506
+ --da-faint:#9699a1;
17507
+ --da-line:rgba(36,37,41,.11);
17508
+ --da-paper:#fff;
17509
+ --da-rail:#2b2c30;
17510
+ position:fixed;
17511
+ inset:0;
17512
+ z-index:2147483000;
17513
+ color:var(--da-ink);
17514
+ font-family:var(--dsw-font-family,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif);
17515
+ font-size:13px;
17516
+ outline:none;
17517
+ }
17518
+ .da-attachment,.da-attachment *{box-sizing:border-box}
17519
+ .da-attachment button,.da-attachment textarea,.da-attachment input{font:inherit}
17520
+ .da-attachment button{cursor:pointer}
17521
+ .da-attachment button:focus-visible,.da-attachment textarea:focus-visible,.da-attachment input:focus-visible{outline:2px solid #6ea2ff;outline-offset:2px}
17522
+ .da-attachment button[aria-disabled=true]{opacity:.38;cursor:default}
17523
+ .da-attachment-backdrop{
17524
+ position:absolute;
17525
+ inset:0;
17526
+ display:grid;
17527
+ place-items:center;
17528
+ padding:24px;
17529
+ background:rgba(18,19,22,.58);
17530
+ backdrop-filter:blur(7px);
17531
+ animation:da-attachment-fade .18s ease-out both;
17532
+ }
17533
+ .da-attachment-dialog{
17534
+ position:relative;
17535
+ isolation:isolate;
17536
+ display:grid;
17537
+ grid-template-rows:minmax(0,1fr) auto;
17538
+ width:min(1240px,calc(100vw - 32px));
17539
+ height:min(860px,calc(100vh - 32px));
17540
+ min-width:520px;
17541
+ min-height:420px;
17542
+ overflow:visible;
17543
+ background:transparent;
17544
+ animation:da-attachment-rise .22s cubic-bezier(.22,1,.36,1) both;
17545
+ }
17546
+ .da-attachment svg{fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
17547
+ .da-attachment-stage{
17548
+ position:relative;
17549
+ display:flex;
17550
+ min-width:0;
17551
+ min-height:0;
17552
+ overflow:auto;
17553
+ padding:34px 100px 30px 34px;
17554
+ background:transparent;
17555
+ scrollbar-width:thin;
17556
+ overscroll-behavior:contain;
17557
+ }
17558
+ .da-attachment-canvas{
17559
+ position:relative;
17560
+ flex:0 0 auto;
17561
+ margin:auto;
17562
+ }
17563
+ .da-attachment-paper{
17564
+ position:relative;
17565
+ transform-origin:top left;
17566
+ overflow:visible;
17567
+ background:transparent;
17568
+ }
17569
+ .da-attachment-paper[data-busy=true]{cursor:progress}
17570
+ .da-attachment-paper>img{position:absolute;inset:0;display:block;width:100%;height:100%;border-radius:inherit;pointer-events:none;user-select:none}
17571
+ .da-attachment-paper>.da-overlay{position:absolute;inset:0;display:block;width:100%;height:100%;overflow:visible;touch-action:none}
17572
+ .da-attachment-paper>.da-overlay[data-tool=select] [data-da-mark]{cursor:move}
17573
+ .da-attachment-rail{
17574
+ position:absolute;
17575
+ z-index:20;
17576
+ top:50%;
17577
+ right:16px;
17578
+ display:flex;
17579
+ flex-direction:column;
17580
+ align-items:center;
17581
+ gap:3px;
17582
+ width:48px;
17583
+ max-height:calc(100% - 34px);
17584
+ overflow-y:auto;
17585
+ padding:7px 6px;
17586
+ transform:translateY(-50%);
17587
+ border:1px solid rgba(255,255,255,.09);
17588
+ border-radius:17px;
17589
+ background:var(--da-rail);
17590
+ color:#fff;
17591
+ box-shadow:0 12px 30px rgba(18,19,23,.3),inset 0 1px 0 rgba(255,255,255,.08);
17592
+ scrollbar-width:none;
17593
+ }
17594
+ .da-attachment-rail::-webkit-scrollbar{display:none}
17595
+ .da-attachment-close svg{width:16px;height:16px}
17596
+ .da-attachment-tool{
17597
+ display:flex;
17598
+ align-items:center;
17599
+ justify-content:center;
17600
+ width:35px;
17601
+ height:35px;
17602
+ flex:0 0 auto;
17603
+ padding:0;
17604
+ border:0;
17605
+ border-radius:10px;
17606
+ background:transparent;
17607
+ color:rgba(255,255,255,.72);
17608
+ font-size:18px;
17609
+ line-height:1;
17610
+ }
17611
+ .da-attachment-tool:hover{background:rgba(255,255,255,.1);color:#fff}
17612
+ .da-attachment-tool[aria-pressed=true]{background:rgba(255,255,255,.19);color:#fff}
17613
+ .da-attachment-tool[aria-disabled=true]:hover{background:transparent;color:rgba(255,255,255,.72)}
17614
+ .da-attachment-tool svg{width:17px;height:17px}
17615
+ .da-attachment-separator{width:22px;height:1px;flex:0 0 auto;margin:3px 0;background:rgba(255,255,255,.15)}
17616
+ .da-attachment-color{
17617
+ position:relative;
17618
+ display:flex;
17619
+ align-items:center;
17620
+ justify-content:center;
17621
+ width:35px;
17622
+ height:35px;
17623
+ flex:0 0 auto;
17624
+ border-radius:10px;
17625
+ cursor:pointer;
17626
+ }
17627
+ .da-attachment-color:hover{background:rgba(255,255,255,.1)}
17628
+ .da-attachment-color::after{content:"";position:absolute;inset:9px;border:2px solid #fff;border-radius:50%;background:var(--da-accent);box-shadow:0 0 0 1px rgba(0,0,0,.15)}
17629
+ .da-attachment-color input{position:absolute;inset:0;width:100%;height:100%;padding:0;border:0;opacity:0;cursor:pointer}
17630
+ .da-attachment-zoom{
17631
+ display:block;
17632
+ min-width:34px;
17633
+ padding:2px 0 3px;
17634
+ color:rgba(255,255,255,.55);
17635
+ font-size:8.5px;
17636
+ font-variant-numeric:tabular-nums;
17637
+ line-height:1;
17638
+ text-align:center;
17639
+ }
17640
+ .da-attachment-footer{
17641
+ display:flex;
17642
+ flex-direction:column;
17643
+ align-items:center;
17644
+ gap:6px;
17645
+ align-self:center;
17646
+ width:auto;
17647
+ margin:0 auto 14px;
17648
+ padding:0;
17649
+ }
17650
+ .da-attachment-status{
17651
+ display:flex;
17652
+ flex-direction:column;
17653
+ align-items:center;
17654
+ gap:3px;
17655
+ max-width:min(560px,calc(100vw - 56px));
17656
+ padding:6px 10px;
17657
+ border-radius:9px;
17658
+ background:rgba(23,24,27,.82);
17659
+ color:rgba(255,255,255,.72);
17660
+ font-size:10px;
17661
+ line-height:1.4;
17662
+ text-align:center;
17663
+ backdrop-filter:blur(12px);
17664
+ }
17665
+ .da-attachment-warning{color:#9a6418}
17666
+ .da-attachment-actions{display:flex;align-items:center;gap:7px;flex:0 0 auto}
17667
+ .da-attachment-action{
17668
+ display:inline-flex;
17669
+ align-items:center;
17670
+ justify-content:center;
17671
+ gap:7px;
17672
+ height:31px;
17673
+ padding:0 11px;
17674
+ border:.5px solid rgba(255,255,255,.28);
17675
+ border-radius:999px;
17676
+ background:rgba(251,251,252,.92);
17677
+ color:#4f5157;
17678
+ font-size:10.5px;
17679
+ font-weight:580;
17680
+ line-height:1;
17681
+ white-space:nowrap;
17682
+ box-shadow:0 5px 18px rgba(5,6,9,.18);
17683
+ }
17684
+ .da-attachment-action:hover{background:#eeeef0;color:var(--da-ink)}
17685
+ .da-attachment-action svg{width:14px;height:14px}
17686
+ .da-attachment-action-primary{border-color:#26272b;background:#26272b;color:#fff;box-shadow:0 1px 2px rgba(0,0,0,.14)}
17687
+ .da-attachment-action-primary:hover{background:#3a3b40;color:#fff}
17688
+ .da-attachment-comment-pill{
17689
+ position:absolute;
17690
+ z-index:15;
17691
+ width:286px;
17692
+ max-width:calc(100% - 16px);
17693
+ padding:3px;
17694
+ border:.5px solid var(--da-line);
17695
+ border-radius:20px;
17696
+ background:color-mix(in srgb,var(--da-paper) 94%,transparent);
17697
+ color:var(--da-ink);
17698
+ box-shadow:0 5px 18px rgba(20,40,80,.14),0 0 0 .5px rgba(20,40,80,.06);
17699
+ backdrop-filter:blur(10px);
17700
+ }
17701
+ .da-attachment-comment-pill textarea{
17702
+ width:100%;
17703
+ min-height:0;
17704
+ max-height:min(180px,42vh);
17705
+ resize:none;
17706
+ padding:7px 10px;
17707
+ border:0;
17708
+ outline:0;
17709
+ background:transparent;
17710
+ color:var(--da-ink);
17711
+ font:inherit;
17712
+ font-size:13px;
17713
+ line-height:1.55;
17714
+ field-sizing:content;
17715
+ overflow-y:auto;
17716
+ scrollbar-width:none;
17717
+ }
17718
+ .da-attachment-comment-pill textarea::-webkit-scrollbar{width:0;height:0}
17719
+ .da-attachment-comment-pill textarea::placeholder{color:#a1a4ab}
17720
+ .da-attachment .da-attachment-comment-pill textarea:focus-visible{outline:0}
17721
+ .da-attachment-confirm{
17722
+ position:absolute;
17723
+ z-index:40;
17724
+ left:50%;
17725
+ bottom:82px;
17726
+ display:flex;
17727
+ align-items:center;
17728
+ justify-content:space-between;
17729
+ gap:20px;
17730
+ width:min(500px,calc(100% - 40px));
17731
+ padding:12px 12px 12px 15px;
17732
+ transform:translateX(-50%);
17733
+ border:1px solid rgba(180,35,24,.2);
17734
+ border-radius:13px;
17735
+ background:rgba(255,252,251,.98);
17736
+ box-shadow:0 14px 38px rgba(50,25,20,.18);
17737
+ }
17738
+ .da-attachment-confirm>div:first-child{display:flex;flex-direction:column;gap:2px;min-width:0}
17739
+ .da-attachment-confirm strong{color:#352f2e;font-size:11.5px;font-weight:650}
17740
+ .da-attachment-confirm span{color:#827875;font-size:9.5px;line-height:1.35}
17741
+ .da-attachment-confirm-actions{display:flex;gap:6px;flex:0 0 auto}
17742
+ .da-attachment-confirm button{height:29px;padding:0 10px;border:.5px solid rgba(36,37,41,.13);border-radius:8px;background:#fff;color:#666970;font-size:10.5px}
17743
+ .da-attachment-confirm button:hover{background:#f1f1f2}
17744
+ .da-attachment-confirm .da-attachment-danger{border-color:rgba(180,35,24,.22);color:#a1281f}
17745
+ .da-attachment-confirm .da-attachment-danger:hover{background:#fae9e7}
17746
+ @keyframes da-attachment-fade{from{opacity:0}to{opacity:1}}
17747
+ @keyframes da-attachment-rise{from{opacity:0;transform:translateY(8px) scale(.992)}to{opacity:1;transform:none}}
17748
+ @media(max-width:760px){
17749
+ .da-attachment-backdrop{padding:0}
17750
+ .da-attachment-dialog{width:100vw;height:100vh;min-width:0;min-height:0}
17751
+ .da-attachment-stage{padding:16px 70px 18px 16px}
17752
+ .da-attachment-rail{right:9px;width:46px}
17753
+ .da-attachment-footer{gap:6px;margin-bottom:10px}
17754
+ .da-attachment-status{max-width:calc(100vw - 32px)}
17755
+ .da-attachment-actions{justify-content:flex-end}
17756
+ .da-attachment-action{flex:0 0 auto}
17757
+ .da-attachment-comment-pill{width:248px}
17758
+ }
17759
+ @media(pointer:coarse){
17760
+ .da-attachment-tool,.da-attachment-color{width:40px;height:40px}
17761
+ .da-attachment-rail{width:53px}
17762
+ }
17763
+ @media(prefers-reduced-motion:reduce){
17764
+ .da-attachment-backdrop,.da-attachment-dialog{animation:none}
17765
+ }
17766
+ `;
17767
+ //#endregion
17768
+ //#region src/client/attachment-annotator/editor.tsx
17769
+ /** A comp-access annotation overlay opened from a composer image thumbnail. */
17770
+ const TOOL_LABELS = {
17771
+ select: "选择",
17772
+ pen: "画笔",
17773
+ arrow: "箭头",
17774
+ rect: "矩形",
17775
+ ellipse: "椭圆",
17776
+ point: "Pin 评论"
17777
+ };
17778
+ const TOOLS = [
17779
+ "select",
17780
+ "pen",
17781
+ "arrow",
17782
+ "rect",
17783
+ "ellipse",
17784
+ "point"
17785
+ ].map((tool) => [tool, TOOL_LABELS[tool]]);
17786
+ const ZOOMS = [
17787
+ 1,
17788
+ 1.5,
17789
+ 2,
17790
+ 4
17791
+ ];
17792
+ const ZOOM_IN = {
17793
+ 1: 1.5,
17794
+ 1.5: 2,
17795
+ 2: 4,
17796
+ 4: 4
17797
+ };
17798
+ const ZOOM_OUT = {
17799
+ 1: 1,
17800
+ 1.5: 1,
17801
+ 2: 1.5,
17802
+ 4: 2
17803
+ };
17804
+ function PointerIcon() {
17805
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
17806
+ viewBox: "0 0 24 24",
17807
+ "aria-hidden": "true",
17808
+ focusable: "false",
17809
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: POINTER_PATH })
17810
+ });
17811
+ }
17812
+ function ToolGlyph({ tool }) {
17813
+ return tool === "select" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PointerIcon, {}) : ToolIcon(tool);
17814
+ }
17815
+ function imagesOf(conversation, attachmentIds) {
17816
+ if (conversation === void 0) return [];
17817
+ return conversation.resolveDraftAttachments(attachmentIds).filter((draft) => draft.kind === "image" && "file" in draft);
17818
+ }
17819
+ /** Where a mark's adjacent comment should be anchored, in figure pixels. */
17820
+ function attachmentFocusPoint(mark) {
17821
+ if (mark.kind === "arrow") return mark.points[mark.points.length - 1] ?? [0, 0];
17822
+ if (mark.kind === "rect" || mark.kind === "ellipse") {
17823
+ const first = mark.points[0] ?? [0, 0];
17824
+ const second = mark.points[1] ?? first;
17825
+ return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2];
17826
+ }
17827
+ if (mark.kind === "pen") {
17828
+ const points = mark.points.length === 0 ? [[0, 0]] : mark.points;
17829
+ const xs = points.map((point) => point[0]);
17830
+ const ys = points.map((point) => point[1]);
17831
+ return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
17832
+ }
17833
+ return mark.points[0] ?? [0, 0];
17834
+ }
17835
+ function canEditAttachment(job, sessionId, input) {
17836
+ return canAttach(job, sessionId, input);
17837
+ }
17838
+ /** Mounted once per Session. It has no visible row until a draft image is clicked. */
17839
+ function AttachmentOverlay(props) {
17840
+ const { store, sessionId, conversation, useInput } = props;
17841
+ const draft = useInput((input) => input.draft);
17842
+ const attachmentIds = useInput((input) => input.attachmentIds);
17843
+ const input = {
17844
+ draft,
17845
+ attachmentIds,
17846
+ phase: useInput((input) => input.phase)
17847
+ };
17848
+ const job = (0, react.useSyncExternalStore)(store.subscribe, store.getSnapshot);
17849
+ const images = imagesOf(conversation, attachmentIds);
17850
+ const imagesRef = (0, react.useRef)(images);
17851
+ const pendingThumbnail = (0, react.useRef)(null);
17852
+ const anchor = (0, react.useRef)(null);
17853
+ imagesRef.current = images;
17854
+ (0, react.useEffect)(() => {
17855
+ const card = anchor.current?.closest("[data-composer-card]");
17856
+ if (card === null || card === void 0) return;
17857
+ const openImage = (event) => {
17858
+ if (!(event instanceof MouseEvent)) return;
17859
+ if (event.button !== 0) return;
17860
+ const target = event.target;
17861
+ if (!(target instanceof Element)) return;
17862
+ const imageNode = target instanceof HTMLImageElement ? target : target.closest("button")?.querySelector("img") ?? null;
17863
+ if (imageNode === null) return;
17864
+ const previewUrl = imageNode.getAttribute("src");
17865
+ if (previewUrl === null) return;
17866
+ const image = imagesRef.current.find((draft) => draft.previewUrl === previewUrl);
17867
+ if (image === void 0 && imagesRef.current.length > 0) return;
17868
+ event.preventDefault();
17869
+ event.stopPropagation();
17870
+ if (image !== void 0) {
17871
+ store.open(sessionId, image);
17872
+ return;
17873
+ }
17874
+ pendingThumbnail.current = previewUrl;
17875
+ };
17876
+ card.addEventListener("click", openImage, true);
17877
+ return () => {
17878
+ card.removeEventListener("click", openImage, true);
17879
+ };
17880
+ }, [sessionId, store]);
17881
+ (0, react.useEffect)(() => {
17882
+ const previewUrl = pendingThumbnail.current;
17883
+ if (previewUrl === null) return;
17884
+ const image = images.find((draft) => draft.previewUrl === previewUrl);
17885
+ if (image === void 0) return;
17886
+ pendingThumbnail.current = null;
17887
+ store.open(sessionId, image);
17888
+ }, [
17889
+ images,
17890
+ sessionId,
17891
+ store
17892
+ ]);
17893
+ (0, react.useEffect)(() => {
17894
+ if (job === null) return;
17895
+ if (job.sessionId !== sessionId || !canEditAttachment(job, sessionId, input)) store.clear();
17896
+ }, [
17897
+ input.attachmentIds,
17898
+ input.phase,
17899
+ job,
17900
+ sessionId,
17901
+ store
17902
+ ]);
17903
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17904
+ ref: anchor,
17905
+ className: "da-attachment-anchor",
17906
+ "aria-hidden": "true"
17907
+ }), job !== null && job.sessionId === sessionId ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentEditor, {
17908
+ ...props,
17909
+ job
17910
+ }, `${job.sessionId}/${job.source.id}`), document.body) : null] });
17911
+ }
17912
+ /** The whole editor is one modal surface; the host's own composer remains intact. */
17913
+ function AttachmentEditor(props) {
17914
+ const { store, sessionId, conversation, useInput, inputActions, job } = props;
17915
+ const input = {
17916
+ draft: useInput((input) => input.draft),
17917
+ attachmentIds: useInput((input) => input.attachmentIds),
17918
+ phase: useInput((input) => input.phase)
17919
+ };
17920
+ const history = useAnnotationHistory(job.marks);
17921
+ const latest = (0, react.useRef)({
17922
+ input,
17923
+ sessionId
17924
+ });
17925
+ latest.current = {
17926
+ input,
17927
+ sessionId
17928
+ };
17929
+ const mounted = (0, react.useRef)(true);
17930
+ const root = (0, react.useRef)(null);
17931
+ const paper = (0, react.useRef)(null);
17932
+ const viewport = (0, react.useRef)(null);
17933
+ const canvasInput = (0, react.useRef)(null);
17934
+ const busyRef = (0, react.useRef)(false);
17935
+ const [previewUrl, setPreviewUrl] = (0, react.useState)();
17936
+ const [tool, setTool] = (0, react.useState)("select");
17937
+ const [color, setColor] = (0, react.useState)("#e03131");
17938
+ const [selectedId, setSelectedId] = (0, react.useState)(null);
17939
+ const [size, setSize] = (0, react.useState)(null);
17940
+ const [zoom, setZoom] = (0, react.useState)(1);
17941
+ const [viewportSize, setViewportSize] = (0, react.useState)({
17942
+ width: 760,
17943
+ height: 520
17944
+ });
17945
+ const [notice, setNotice] = (0, react.useState)("");
17946
+ const [busy, setBusy] = (0, react.useState)(false);
17947
+ const [confirmClose, setConfirmClose] = (0, react.useState)(false);
17948
+ (0, react.useEffect)(() => {
17949
+ mounted.current = true;
17950
+ root.current?.focus();
17951
+ return () => {
17952
+ mounted.current = false;
17953
+ };
17954
+ }, []);
17955
+ (0, react.useEffect)(() => {
17956
+ const url = URL.createObjectURL(job.source.file);
17957
+ setPreviewUrl(url);
17958
+ return () => {
17959
+ URL.revokeObjectURL(url);
17960
+ };
17961
+ }, [job.source.file]);
17962
+ (0, react.useEffect)(() => {
17963
+ store.change(job.source, history.marks);
17964
+ }, [
17965
+ history.marks,
17966
+ job.source,
17967
+ store
17968
+ ]);
17969
+ (0, react.useEffect)(() => {
17970
+ const node = viewport.current;
17971
+ /* v8 ignore next -- the effect runs after the stage ref has attached. */
17972
+ if (node === null) return;
17973
+ const measure = () => {
17974
+ setViewportSize({
17975
+ width: Math.max(280, node.clientWidth),
17976
+ height: Math.max(240, node.clientHeight)
17977
+ });
17978
+ };
17979
+ measure();
17980
+ const observer = new ResizeObserver(measure);
17981
+ observer.observe(node);
17982
+ return () => {
17983
+ observer.disconnect();
17984
+ };
17985
+ }, []);
17986
+ const scale = (size === null ? 1 : Math.min(1, Math.max(.1, (viewportSize.width - 112) / size.width), Math.max(.1, (viewportSize.height - 56) / size.height))) * zoom;
17987
+ const selected = history.marks.find((mark) => mark.id === selectedId) ?? null;
17988
+ const ready = canEditAttachment(job, sessionId, input) && conversation !== void 0;
17989
+ const percentage = Math.round(zoom * 100);
17990
+ const select = (id) => {
17991
+ setSelectedId(id);
17992
+ };
17993
+ const remove = (id) => {
17994
+ history.commit(removedMarks(history.marks, id));
17995
+ setSelectedId(null);
17996
+ };
17997
+ const requestClose = () => {
17998
+ canvasInput.current?.cancel();
17999
+ if (history.marks.length === 0) store.clear();
18000
+ else setConfirmClose(true);
18001
+ };
18002
+ const finish = async (download) => {
18003
+ const liveConversation = conversation;
18004
+ if (busyRef.current || size === null || history.marks.length === 0) return;
18005
+ if (!download && (!ready || liveConversation === void 0)) return;
18006
+ canvasInput.current?.cancel();
18007
+ busyRef.current = true;
18008
+ setBusy(true);
18009
+ setNotice(download ? "正在生成 PNG…" : "正在生成标注副本…");
18010
+ try {
18011
+ const file = await exportAttachment(job.source.file, size, history.marks);
18012
+ if (!mounted.current || store.getSnapshot()?.source !== job.source) return;
18013
+ const editor = root.current;
18014
+ if (editor !== null && typeof editor.checkVisibility === "function" && !editor.checkVisibility()) throw new Error("标注浮层已关闭,请重新打开后重试");
18015
+ if (download) {
18016
+ downloadAttachment(file);
18017
+ setNotice("已下载 PNG;原图和输入框未改变。");
18018
+ return;
18019
+ }
18020
+ if (store.getSnapshot()?.marks !== history.marks) throw new Error("生成过程中批注已更改,请重新生成");
18021
+ /* v8 ignore next -- the callable form above already refused an absent Host. */
18022
+ if (liveConversation === void 0) return;
18023
+ const live = latest.current;
18024
+ const id = attachCopy(job, live.sessionId, live.input, liveConversation, inputActions, file);
18025
+ store.rememberCopy(job.source, history.marks, id);
18026
+ store.clear();
18027
+ } catch (error) {
18028
+ if (mounted.current && store.getSnapshot() !== null) setNotice(`生成或加入副本失败:${String(error)}。批注已保留,可重试或下载。`);
18029
+ } finally {
18030
+ busyRef.current = false;
18031
+ if (mounted.current && store.getSnapshot() !== null) setBusy(false);
18032
+ }
18033
+ };
18034
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
18035
+ ref: root,
18036
+ className: "da-attachment",
18037
+ tabIndex: -1,
18038
+ role: "dialog",
18039
+ "aria-modal": "true",
18040
+ "aria-label": "附图标注编辑器",
18041
+ onKeyDown: (event) => {
18042
+ event.stopPropagation();
18043
+ if (event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLInputElement) return;
18044
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
18045
+ event.preventDefault();
18046
+ if (event.shiftKey) history.redo();
18047
+ else history.undo();
18048
+ } else if (event.key === "Escape") {
18049
+ canvasInput.current?.cancel();
18050
+ setSelectedId(null);
18051
+ requestClose();
18052
+ } else if ((event.key === "Delete" || event.key === "Backspace") && selectedId !== null) {
18053
+ event.preventDefault();
18054
+ remove(selectedId);
18055
+ }
18056
+ },
18057
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: ATTACHMENT_CSS }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18058
+ className: "da-attachment-backdrop",
18059
+ onPointerDown: (event) => {
18060
+ if (event.target === event.currentTarget) requestClose();
18061
+ },
18062
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18063
+ className: "da-attachment-dialog",
18064
+ children: [
18065
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18066
+ className: "da-attachment-stage",
18067
+ ref: viewport,
18068
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18069
+ className: "da-attachment-canvas",
18070
+ style: size === null ? {} : {
18071
+ width: size.width * scale,
18072
+ height: size.height * scale
18073
+ },
18074
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18075
+ ref: paper,
18076
+ className: "da-attachment-paper",
18077
+ "data-busy": busy || void 0,
18078
+ style: size === null ? {} : {
18079
+ width: size.width,
18080
+ height: size.height,
18081
+ transform: `scale(${scale})`
18082
+ },
18083
+ onPointerDownCapture: (event) => {
18084
+ if (!busyRef.current) return;
18085
+ event.preventDefault();
18086
+ event.stopPropagation();
18087
+ },
18088
+ children: [
18089
+ previewUrl !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
18090
+ src: previewUrl,
18091
+ alt: "待标注原图",
18092
+ draggable: false,
18093
+ onLoad: (event) => {
18094
+ const image = event.currentTarget;
18095
+ if (image.naturalWidth === 0 || image.naturalHeight === 0) {
18096
+ setSize(null);
18097
+ setNotice("图片尺寸无效。");
18098
+ return;
18099
+ }
18100
+ setSize({
18101
+ width: image.naturalWidth,
18102
+ height: image.naturalHeight
18103
+ });
18104
+ },
18105
+ onError: () => {
18106
+ setSize(null);
18107
+ setNotice("原图加载失败;请重新添加图片后重试。");
18108
+ }
18109
+ }),
18110
+ size !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Canvas, {
18111
+ width: size.width,
18112
+ height: size.height,
18113
+ marks: history.marks,
18114
+ tool,
18115
+ color,
18116
+ containerRef: paper,
18117
+ inputRef: canvasInput,
18118
+ anchoring: false,
18119
+ scale,
18120
+ selectedId,
18121
+ onSelect: select,
18122
+ onAdd: (mark) => {
18123
+ if (history.marks.length >= 100) {
18124
+ setNotice("单张图片最多 100 条批注。");
18125
+ return;
18126
+ }
18127
+ history.commit([...history.marks, mark]);
18128
+ },
18129
+ onMove: (id, points, anchor) => {
18130
+ const next = movedMarks(history.marks, id, points, anchor);
18131
+ if (next !== null) history.commit(next);
18132
+ },
18133
+ onUpdate: (id, patch) => {
18134
+ history.commit(patchedMarks(history.marks, id, patch));
18135
+ },
18136
+ onRemove: remove,
18137
+ onEdit: (id) => {
18138
+ setSelectedId(id);
18139
+ setTool("select");
18140
+ },
18141
+ onDrawn: (id) => {
18142
+ setSelectedId(id);
18143
+ if (tool !== "pen") setTool("select");
18144
+ }
18145
+ }),
18146
+ selected !== null && size !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentPill, {
18147
+ mark: selected,
18148
+ size,
18149
+ scale,
18150
+ onChange: (value) => {
18151
+ history.amend((marks) => detailedMarks(marks, selected.id, value));
18152
+ },
18153
+ onClose: () => {
18154
+ setSelectedId(null);
18155
+ }
18156
+ })
18157
+ ]
18158
+ })
18159
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18160
+ className: "da-attachment-rail",
18161
+ role: "toolbar",
18162
+ "aria-label": "绘图工具",
18163
+ children: [
18164
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18165
+ type: "button",
18166
+ className: "da-attachment-tool da-attachment-close",
18167
+ "aria-label": "关闭附图标注",
18168
+ title: "关闭",
18169
+ onClick: requestClose,
18170
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CloseIcon, {})
18171
+ }),
18172
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "da-attachment-separator" }),
18173
+ TOOLS.map(([value, label]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18174
+ type: "button",
18175
+ className: "da-attachment-tool",
18176
+ "aria-label": label,
18177
+ title: label,
18178
+ "aria-pressed": tool === value,
18179
+ onClick: () => {
18180
+ canvasInput.current?.cancel();
18181
+ setTool(value);
18182
+ },
18183
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolGlyph, { tool: value })
18184
+ }, value)),
18185
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "da-attachment-separator" }),
18186
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
18187
+ className: "da-attachment-color",
18188
+ title: "批注颜色",
18189
+ style: { "--da-accent": color },
18190
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
18191
+ type: "color",
18192
+ "aria-label": "批注颜色",
18193
+ value: color,
18194
+ onChange: (event) => {
18195
+ setColor(event.target.value);
18196
+ if (selectedId !== null) history.commit(patchedMarks(history.marks, selectedId, { color: event.target.value }));
18197
+ }
18198
+ })
18199
+ }),
18200
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18201
+ type: "button",
18202
+ className: "da-attachment-tool",
18203
+ "aria-label": "撤销",
18204
+ title: "撤销",
18205
+ "aria-disabled": history.depth.past === 0,
18206
+ onClick: history.undo,
18207
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UndoIcon, {})
18208
+ }),
18209
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18210
+ type: "button",
18211
+ className: "da-attachment-tool",
18212
+ "aria-label": "重做",
18213
+ title: "重做",
18214
+ "aria-disabled": history.depth.future === 0,
18215
+ onClick: history.redo,
18216
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RedoIcon, {})
18217
+ }),
18218
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "da-attachment-separator" }),
18219
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18220
+ type: "button",
18221
+ className: "da-attachment-tool",
18222
+ "aria-label": "缩小",
18223
+ title: "缩小",
18224
+ "aria-disabled": zoom === ZOOMS[0],
18225
+ onClick: () => {
18226
+ setZoom(ZOOM_OUT[zoom]);
18227
+ },
18228
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18229
+ "aria-hidden": "true",
18230
+ children: "−"
18231
+ })
18232
+ }),
18233
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
18234
+ className: "da-attachment-zoom",
18235
+ "aria-label": "当前缩放",
18236
+ children: [percentage, "%"]
18237
+ }),
18238
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18239
+ type: "button",
18240
+ className: "da-attachment-tool",
18241
+ "aria-label": "放大",
18242
+ title: "放大",
18243
+ "aria-disabled": zoom === ZOOMS[ZOOMS.length - 1],
18244
+ onClick: () => {
18245
+ setZoom(ZOOM_IN[zoom]);
18246
+ },
18247
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18248
+ "aria-hidden": "true",
18249
+ children: "+"
18250
+ })
18251
+ })
18252
+ ]
18253
+ })]
18254
+ }),
18255
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("footer", {
18256
+ className: "da-attachment-footer",
18257
+ children: [(notice !== "" || !ready) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18258
+ className: "da-attachment-status",
18259
+ children: [notice !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18260
+ role: "status",
18261
+ "aria-live": "polite",
18262
+ children: notice
18263
+ }), !ready && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18264
+ className: "da-attachment-warning",
18265
+ children: "原附件已移除、已发送,或输入框正在发送。仍可下载 PNG。"
18266
+ })]
18267
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18268
+ className: "da-attachment-actions",
18269
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
18270
+ type: "button",
18271
+ className: "da-attachment-action",
18272
+ "aria-disabled": busy || size === null || history.marks.length === 0,
18273
+ onClick: () => {
18274
+ finish(true);
18275
+ },
18276
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, {}), "下载 PNG"]
18277
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
18278
+ type: "button",
18279
+ className: "da-attachment-action da-attachment-action-primary",
18280
+ "aria-disabled": busy || size === null || history.marks.length === 0 || !ready,
18281
+ title: "生成标注图片,并把逐条评论写入输入框;原图不变,也不会自动发送",
18282
+ onClick: () => {
18283
+ finish(false);
18284
+ },
18285
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyIcon, {}), "加入输入框"]
18286
+ })]
18287
+ })]
18288
+ }),
18289
+ confirmClose && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18290
+ className: "da-attachment-confirm",
18291
+ role: "alert",
18292
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: "放弃这些本地批注?" }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: "原图不会改变;未生成的批注会在关闭后清除。" })] }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18293
+ className: "da-attachment-confirm-actions",
18294
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18295
+ type: "button",
18296
+ onClick: () => {
18297
+ setConfirmClose(false);
18298
+ },
18299
+ children: "继续编辑"
18300
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18301
+ type: "button",
18302
+ className: "da-attachment-danger",
18303
+ onClick: () => {
18304
+ store.clear();
18305
+ },
18306
+ children: "放弃批注"
18307
+ })]
18308
+ })]
18309
+ })
18310
+ ]
18311
+ })
18312
+ })]
18313
+ });
18314
+ }
18315
+ function CommentPill(props) {
18316
+ const { mark, size, scale } = props;
18317
+ const input = (0, react.useRef)(null);
18318
+ (0, react.useEffect)(() => {
18319
+ input.current?.focus();
18320
+ }, [mark.id]);
18321
+ const [x, y] = attachmentFocusPoint(mark);
18322
+ const right = x > size.width * .62;
18323
+ const below = y < 92;
18324
+ const translateX = right ? "calc(-100% - 14px)" : "14px";
18325
+ const translateY = below ? "14px" : "calc(-100% - 14px)";
18326
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18327
+ className: "da-attachment-comment-pill",
18328
+ role: "group",
18329
+ "aria-label": "批注评论",
18330
+ style: {
18331
+ left: `${x}px`,
18332
+ top: `${y}px`,
18333
+ transform: `scale(${1 / scale}) translate(${translateX}, ${translateY})`,
18334
+ transformOrigin: `${right ? "right" : "left"} ${below ? "top" : "bottom"}`
18335
+ },
18336
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
18337
+ ref: input,
18338
+ "data-mark": mark.id,
18339
+ "aria-label": `批注 ${mark.id} 评论`,
18340
+ placeholder: "说明这里要怎么改…",
18341
+ rows: 1,
18342
+ maxLength: MAX_DETAIL_CHARS,
18343
+ value: mark.detail ?? "",
18344
+ onChange: (event) => {
18345
+ props.onChange(event.target.value);
18346
+ },
18347
+ onKeyDown: (event) => {
18348
+ if (event.key === "Escape") {
18349
+ event.preventDefault();
18350
+ props.onClose();
18351
+ }
18352
+ }
18353
+ })
18354
+ });
18355
+ }
18356
+ //#endregion
18357
+ //#region src/client/attachment-annotator/register.ts
18358
+ const ATTACHMENT_BODY_ID = "dsh-figmark/attachment";
18359
+ /**
18360
+ * One invisible floating seat. It listens only to its Session's native draft
18361
+ * thumbnails, so the shell keeps ownership of uploads, removal and sending.
18362
+ */
18363
+ function registerAttachmentAnnotator(ctx, slots) {
18364
+ const store = createAttachmentStore();
18365
+ ctx.effect(() => () => {
18366
+ store.dispose();
18367
+ }, "dsh-figmark: attachment draft lifecycle");
18368
+ const injected = (_sessionId) => ({
18369
+ store,
18370
+ conversation: attachmentConversation(ctx.get("conversation"))
18371
+ });
18372
+ ctx.effect(() => slots.inject("conversation.input.overlay", () => slots.register({
18373
+ name: "conversation.input.overlay",
18374
+ id: ATTACHMENT_BODY_ID,
18375
+ order: 80,
18376
+ inject: injected
18377
+ }, AttachmentOverlay)), "dsh-figmark: attachment overlay");
18378
+ }
18379
+ //#endregion
17281
18380
  //#region src/client/index.tsx
17282
18381
  /** Plugin name reported to the client module loader. */
17283
18382
  const name = "dsh-figmark-client";
@@ -17530,6 +18629,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
17530
18629
  localeId: locale.getLocale().locale ?? "zh"
17531
18630
  })
17532
18631
  }, PendingCard)), "dsh-figmark: parked annotation");
18632
+ registerAttachmentAnnotator(ctx, slots);
17533
18633
  }
17534
18634
  //#endregion
17535
18635
  exports.BODY_ID = BODY_ID;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-figmark",
3
- "version": "1.2.2",
3
+ "version": "1.2.3",
4
4
  "private": false,
5
5
  "description": "DSH 插件:在右侧栏预览里对科研配图、SVG、PDF 与本机网页直接圈画批注,标注写进侧车文件并可一键交给智能体改图源",
6
6
  "license": "MIT",