dsh-figmark 1.2.2 → 1.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/client.js CHANGED
@@ -73,15 +73,17 @@ window.__ModuleLoader__.load({
73
73
  reveal_hidden: "元素目前不可见,请先展开或翻到对应页面",
74
74
  reveal_wrong_surface: "无法切换到标注所在的页面",
75
75
  reveal_timeout: "页面没有响应定位请求",
76
- serviceKicker: "LOCAL RUNTIME",
77
76
  serviceTitle: "本机网页",
78
- serviceHint: "只标注已经在 http:// 上运行、可交互的页面。静态 HTML 只读预览,不进入标注。",
79
- serviceSearch: "输入 localhost:5173 或运行中的地址",
77
+ serviceHint: "打开正在运行的本机网页,直接圈画与批注。",
78
+ serviceSearch: "搜索页面,或输入 localhost:5173",
80
79
  serviceOpen: "打开",
81
80
  serviceOpening: "正在连接",
82
81
  serviceLocal: "正在运行",
83
82
  serviceScanning: "检索中",
84
83
  serviceRescan: "重新检索",
84
+ serviceNoMatch: "没有匹配的页面",
85
+ serviceChooseMatch: "找到多个页面,请选择下方的一项。",
86
+ serviceSearchHint: "换个关键词,或输入完整的本机地址。",
85
87
  serviceEmpty: "没有发现本机服务",
86
88
  serviceEmptyHint: "先启动开发服务器,或在上方直接输入地址。",
87
89
  serviceBadAddress: "请输入本机 HTTP 地址",
@@ -169,18 +171,25 @@ window.__ModuleLoader__.load({
169
171
  detailHint: "当前选中",
170
172
  detailUnset: "未选中",
171
173
  detailIdle: "在图上点一下、框一块或画一笔,就能为那处写说明。",
172
- cardOpen: "打开完整标注",
174
+ cardOpen: "打开原文件",
173
175
  cardOpenLive: "回到本机网页",
174
176
  cardCaptureOmitted: "位置标注",
175
177
  cardCaptureFailed: "截图失败",
176
- cardMore: "还有 {n} 处",
178
+ cardMore: "查看其余 {n} 处",
179
+ cardExpand: "查看完整批注",
180
+ cardCollapse: "收起详情",
181
+ cardOpenFailed: "未能打开原内容,请重试。",
177
182
  cardGoal: "目标",
178
183
  cardPage: "第 {n} 页",
184
+ cardPages: "共 {n} 页",
179
185
  cardCount: "{n} 处标注",
180
186
  pendingCount: "{n}",
181
187
  pendingRemove: "移除附图标注",
182
- pendingSendHint: "发送当前对话框中的全部标注",
183
- pendingSendNow: "发送全部标注",
188
+ pendingSendHint: "发送当前消息及全部待发送标注",
189
+ pendingSendMessage: "发送消息",
190
+ pendingSending: "发送中…",
191
+ pendingUnavailable: "当前输入框暂时无法发送。",
192
+ pendingSendNow: "发送这份标注",
184
193
  pendingFailed: "没能发送:"
185
194
  };
186
195
  /** English copy. */
@@ -223,15 +232,17 @@ window.__ModuleLoader__.load({
223
232
  reveal_hidden: "The element is not visible yet",
224
233
  reveal_wrong_surface: "Could not switch to the mark’s page",
225
234
  reveal_timeout: "The page did not answer the locate request",
226
- serviceKicker: "LOCAL RUNTIME",
227
235
  serviceTitle: "Local pages",
228
- serviceHint: "Annotate interactive pages already served over http://. Static HTML is preview-only.",
229
- serviceSearch: "localhost:5173 or another running address",
236
+ serviceHint: "Open a running local page to draw and comment.",
237
+ serviceSearch: "Find a page or enter localhost:5173",
230
238
  serviceOpen: "Open",
231
239
  serviceOpening: "Connecting",
232
240
  serviceLocal: "Running",
233
241
  serviceScanning: "Scanning",
234
242
  serviceRescan: "Scan again",
243
+ serviceNoMatch: "No matching pages",
244
+ serviceChooseMatch: "Several pages match. Choose one below.",
245
+ serviceSearchHint: "Try another keyword or enter the full local address.",
235
246
  serviceEmpty: "No local services found",
236
247
  serviceEmptyHint: "Start a development server, or type its address above.",
237
248
  serviceBadAddress: "Enter a local HTTP address",
@@ -319,18 +330,25 @@ window.__ModuleLoader__.load({
319
330
  detailHint: "selected",
320
331
  detailUnset: "nothing selected",
321
332
  detailIdle: "Click the figure, or box a region or draw a stroke, to write about that spot.",
322
- cardOpen: "Open the full annotation",
333
+ cardOpen: "Open source file",
323
334
  cardOpenLive: "Return to the local page",
324
335
  cardCaptureOmitted: "location only",
325
336
  cardCaptureFailed: "screenshot failed",
326
- cardMore: "and {n} more",
337
+ cardMore: "Show {n} more",
338
+ cardExpand: "Review all annotations",
339
+ cardCollapse: "Collapse details",
340
+ cardOpenFailed: "Could not open the source. Try again.",
327
341
  cardGoal: "Goal",
328
342
  cardPage: "page {n}",
343
+ cardPages: "{n} pages",
329
344
  cardCount: "{n} annotations",
330
345
  pendingCount: "{n}",
331
346
  pendingRemove: "Remove the annotation",
332
- pendingSendHint: "Send every annotation in this composer",
333
- pendingSendNow: "Send every annotation",
347
+ pendingSendHint: "Send the message and all staged reviews",
348
+ pendingSendMessage: "Send message",
349
+ pendingSending: "Sending…",
350
+ pendingUnavailable: "This composer cannot send right now.",
351
+ pendingSendNow: "Send this review",
334
352
  pendingFailed: "Could not send: "
335
353
  };
336
354
  /** Locale namespace this dictionary registers under. */
@@ -3808,6 +3826,7 @@ window.__ModuleLoader__.load({
3808
3826
  fontSize: pinFontSizeOf(unit),
3809
3827
  fontWeight: 700,
3810
3828
  fill: PIN_NUMBER_COLOR,
3829
+ stroke: "none",
3811
3830
  textAnchor: "middle",
3812
3831
  dominantBaseline: "central",
3813
3832
  children: number
@@ -4026,6 +4045,7 @@ window.__ModuleLoader__.load({
4026
4045
  fontSize,
4027
4046
  fontWeight: 600,
4028
4047
  fill: CODE_TEXT_COLOR,
4048
+ stroke: "none",
4029
4049
  dominantBaseline: "central",
4030
4050
  children: code
4031
4051
  })] });
@@ -4813,14 +4833,14 @@ window.__ModuleLoader__.load({
4813
4833
  * Upload one file as a draft attachment without putting it in the rail yet.
4814
4834
  * @param conversation - the conversation service.
4815
4835
  * @param sessionId - session the draft belongs to.
4816
- * @param file - the file to hold, when there is one.
4836
+ * @param files - the rendered review images to hold.
4817
4837
  * @returns the ids the send will add, empty when the file could not be made into one.
4818
4838
  */
4819
- function holdFile(conversation, sessionId, file) {
4839
+ function holdFiles(conversation, sessionId, files) {
4820
4840
  const create = conversation.createDrafts?.bind(conversation);
4821
- if (file === void 0 || create === void 0) return [];
4841
+ if (files.length === 0 || create === void 0) return [];
4822
4842
  try {
4823
- return create(sessionId, [file]).map((draft) => draft.id);
4843
+ return create(sessionId, files);
4824
4844
  } catch {
4825
4845
  return [];
4826
4846
  }
@@ -4831,9 +4851,10 @@ window.__ModuleLoader__.load({
4831
4851
  * @param sessionId - session the draft belongs to.
4832
4852
  * @param message - the message, verbatim.
4833
4853
  * @param file - the review picture, when the surface has one to attach.
4854
+ * @param additionalFiles - marked page images of a multi-page review.
4834
4855
  * @returns what was armed, or undefined when this session cannot be armed.
4835
4856
  */
4836
- function armReview(conversation, sessionId, message, file) {
4857
+ function armReview(conversation, sessionId, message, file, additionalFiles = []) {
4837
4858
  let shell;
4838
4859
  try {
4839
4860
  shell = conversation.input.shell(sessionId);
@@ -4841,10 +4862,12 @@ window.__ModuleLoader__.load({
4841
4862
  return;
4842
4863
  }
4843
4864
  if (typeof shell.submit !== "function") return void 0;
4865
+ const drafts = holdFiles(conversation, sessionId, file === void 0 ? additionalFiles : [file, ...additionalFiles]);
4844
4866
  return {
4845
4867
  shell,
4846
4868
  text: message,
4847
- attachmentIds: holdFile(conversation, sessionId, file)
4869
+ attachmentIds: drafts.map((draft) => draft.id),
4870
+ previewUrls: drafts.flatMap((draft) => draft.previewUrl === void 0 ? [] : [draft.previewUrl])
4848
4871
  };
4849
4872
  }
4850
4873
  /**
@@ -4907,12 +4930,13 @@ window.__ModuleLoader__.load({
4907
4930
  for (const claim of claims) {
4908
4931
  const { text, attachmentIds } = claim.armed;
4909
4932
  if (draft.includes(text)) continue;
4910
- if (attachmentIds.length > 0) {
4911
- if (!attach(shell, attachmentIds)) {
4933
+ const missing = attachmentIds.filter((id) => !live.attachmentIds?.includes(id));
4934
+ if (missing.length > 0) {
4935
+ if (!attach(shell, missing)) {
4912
4936
  unwind(shell, typed, added);
4913
4937
  return;
4914
4938
  }
4915
- added.push(...attachmentIds);
4939
+ added.push(...missing);
4916
4940
  }
4917
4941
  draft = draft.trim() === "" ? text : `${draft}\n\n${text}`;
4918
4942
  }
@@ -4972,7 +4996,7 @@ window.__ModuleLoader__.load({
4972
4996
  try {
4973
4997
  shell.setDraft(merge.typed);
4974
4998
  } catch {}
4975
- for (const claim of claims) for (const id of claim.armed.attachmentIds) shell.removeAttachment?.(id);
4999
+ for (const claim of claims) if (claim.armed.staged !== true) for (const id of claim.armed.attachmentIds) shell.removeAttachment?.(id);
4976
5000
  }
4977
5001
  /**
4978
5002
  * Take one card off whatever patch its shell is currently carrying.
@@ -5045,9 +5069,9 @@ window.__ModuleLoader__.load({
5045
5069
  const send = () => {
5046
5070
  try {
5047
5071
  original.apply(shell, args);
5048
- settleClaims(shell, claims, merge);
5049
5072
  } finally {
5050
5073
  waiting = false;
5074
+ settleClaims(shell, claims, merge);
5051
5075
  }
5052
5076
  };
5053
5077
  if (wroteThrough(shell, merge)) {
@@ -5100,13 +5124,27 @@ window.__ModuleLoader__.load({
5100
5124
  */
5101
5125
  function parkInComposer(input) {
5102
5126
  const { document: document_, locale, paths, sessionId } = input;
5103
- const message = buildAnnotationMessage(document_, paths, locale, input.file !== void 0, input.pageImages ?? 0, input.pageCapture);
5127
+ let armed = input.conversation === void 0 ? void 0 : armReview(input.conversation, sessionId, "", input.file, input.files);
5128
+ const planned = (input.file === void 0 ? 0 : 1) + (input.files?.length ?? 0);
5129
+ const attached = armed?.attachmentIds.length ?? planned;
5130
+ const pageCapture = input.pageCapture === "attached" && attached === 0 ? "failed" : input.pageCapture;
5131
+ const message = buildAnnotationMessage(document_, paths, locale, attached > 0, Math.min(input.pageImages ?? 0, attached), pageCapture);
5132
+ if (armed !== void 0 && input.conversation !== void 0) {
5133
+ const ids = armed.attachmentIds.length > 0 ? armed.attachmentIds : holdFiles(input.conversation, sessionId, [new File([message], "annotation.txt", { type: "text/plain" })]).map((draft) => draft.id);
5134
+ if (ids.length > 0 && attach(armed.shell, ids)) armed = {
5135
+ ...armed,
5136
+ attachmentIds: ids,
5137
+ staged: true
5138
+ };
5139
+ else {
5140
+ for (const id of ids) input.conversation.releaseDraftAttachment?.(id);
5141
+ armed = void 0;
5142
+ }
5143
+ }
5104
5144
  rememberDelivered(message, input.thumb);
5105
- const block = matchAnnotation(message, 0);
5106
- const armed = input.conversation === void 0 ? void 0 : armReview(input.conversation, sessionId, message, input.file);
5107
5145
  parkAnnotation({
5108
5146
  sessionId,
5109
- data: block?.data ?? {
5147
+ data: matchAnnotation(message, 0)?.data ?? {
5110
5148
  file: document_.figure.path,
5111
5149
  scope: "",
5112
5150
  target: "",
@@ -5117,7 +5155,10 @@ window.__ModuleLoader__.load({
5117
5155
  },
5118
5156
  raw: message,
5119
5157
  conversation: input.conversation,
5120
- armed,
5158
+ armed: armed === void 0 ? void 0 : {
5159
+ ...armed,
5160
+ text: message
5161
+ },
5121
5162
  send: input.send
5122
5163
  });
5123
5164
  }
@@ -5498,9 +5539,15 @@ window.__ModuleLoader__.load({
5498
5539
  */
5499
5540
  function figureSvg(layer, lit = []) {
5500
5541
  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>`;
5542
+ const litMarkup = lightMarkup(layer.markup, lit);
5543
+ const root = new DOMParser().parseFromString(litMarkup, "image/svg+xml").documentElement;
5544
+ root.setAttribute("x", "0");
5545
+ root.setAttribute("y", "0");
5546
+ root.setAttribute("width", String(layer.width));
5547
+ root.setAttribute("height", String(layer.height));
5548
+ if (layer.viewBox !== void 0) root.setAttribute("viewBox", layer.viewBox);
5549
+ root.setAttribute("preserveAspectRatio", "xMidYMid meet");
5550
+ return new XMLSerializer().serializeToString(root);
5504
5551
  }
5505
5552
  /**
5506
5553
  * Encode bytes as a `data:` URL.
@@ -8784,19 +8831,20 @@ body[data-ds-dark-theme] .da-root{--da-quiet:#9aa0a8}
8784
8831
  border:0.5px solid var(--dsw-alias-border-l2,rgba(128,128,128,.3));border-radius:12px;
8785
8832
  background:var(--dsw-alias-bg-base,rgba(255,255,255,.72));
8786
8833
  box-shadow:0 1px 2px rgba(20,40,80,.04);white-space:normal}
8787
- button.da-card{appearance:none;font-family:inherit;cursor:pointer;transition:border-color .15s ease,box-shadow .15s ease,background .15s ease}
8788
- button.da-card:hover{border-color:var(--dsw-alias-border-l3,rgba(128,128,128,.46));background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.08));
8789
- box-shadow:0 3px 10px rgba(20,40,80,.07)}
8790
- button.da-card:focus-visible{outline:2px solid var(--da-accent,#176df2);outline-offset:1px}
8791
- .da-card-head{display:flex;align-items:center;gap:8px;min-width:0;padding:7px 9px;
8834
+ /* Review, source navigation and delivery are separate native buttons. */
8835
+ .da-card button,.da-staged-send{appearance:none;font:inherit;color:inherit;cursor:pointer}
8836
+ .da-card button:focus-visible,.da-staged-send:focus-visible{outline:2px solid var(--da-accent,#176df2);outline-offset:-2px}
8837
+ .da-card button:hover,.da-staged-send:hover{background:var(--dsw-alias-bg-layer-2,rgba(128,128,128,.08));color:var(--da-accent,#176df2)}
8838
+ .da-card-head{width:100%;box-sizing:border-box;border:0;text-align:left;display:flex;align-items:center;gap:8px;min-width:0;padding:7px 9px;
8792
8839
  border-bottom:0.5px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2));
8793
8840
  background:var(--dsw-alias-bg-layer-1,rgba(128,128,128,.025))}
8841
+ .da-card-head-static{cursor:default}
8794
8842
  .da-card-shot{position:relative;display:flex;flex:0 0 auto;align-items:center;justify-content:center;width:34px;height:34px;overflow:hidden;border-radius:7px;
8795
8843
  border:0.5px solid var(--dsw-alias-border-l2,rgba(128,128,128,.18));
8796
8844
  background:var(--dsw-alias-bg-layer-3,rgba(128,128,128,.1));color:var(--da-accent,#176df2)}
8797
8845
  /* The picture covers the glyph; a picture that fails hides itself, and the glyph
8798
8846
  underneath it all along is what is left. A miss is never an empty box. */
8799
- .da-card-shot img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover}
8847
+ .da-card-shot img{position:absolute;inset:0;width:100%;height:100%;object-fit:contain}
8800
8848
  .da-card-shot img[hidden]{display:none}
8801
8849
  .da-card-glyph{width:18px;height:18px}
8802
8850
  .da-card-text{display:flex;flex:1 1 auto;flex-direction:column;gap:2px;min-width:0}
@@ -8819,10 +8867,11 @@ button.da-card:focus-visible{outline:2px solid var(--da-accent,#176df2);outline-
8819
8867
  border-radius:50%;background:var(--dsw-alias-bg-layer-3,rgba(128,128,128,.1));
8820
8868
  color:var(--dsw-alias-label-tertiary,inherit)}
8821
8869
  .da-card-go svg{width:14px;height:14px;fill:none;stroke:currentColor;stroke-width:1.5;stroke-linecap:round;stroke-linejoin:round}
8822
- button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242,.14));color:var(--da-accent,#176df2)}
8870
+ .da-card-head:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242,.14));color:var(--da-accent,#176df2)}
8823
8871
  /* Each mark is a compact record, not a line of debug output. The row head answers
8824
8872
  "which mark and where"; the element name and selector follow only when the message
8825
8873
  carried them; the reader's sentence is the strongest text. */
8874
+ .da-card[data-expanded="true"] .da-card-go{transform:rotate(90deg)}
8826
8875
  .da-card-body{display:flex;flex-direction:column;min-width:0;padding:0 9px 2px}
8827
8876
  .da-card-row{display:flex;flex-direction:column;gap:2px;min-width:0;padding:6px 0}
8828
8877
  .da-card-row+.da-card-row{border-top:0.5px solid var(--dsw-alias-border-l2,rgba(128,128,128,.14))}
@@ -8924,21 +8973,23 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
8924
8973
  background:none;font:inherit;font-size:13px;line-height:1;cursor:pointer;opacity:.65}
8925
8974
  .da-staged-result-x:hover{opacity:1;background:var(--dsw-alias-bg-layer-2,rgba(255,255,255,.72))}
8926
8975
  .da-staged-result-x:focus-visible{outline:2px solid var(--da-accent,#176df2);outline-offset:1px}
8927
- /* One figure, one picture. The message that carries an annotation also attaches the
8928
- review picture, and the shell draws that attachment full width above the bubble —
8929
- so the same figure appeared twice, once huge and once as the card's 38px copy. The
8930
- card can open that figure itself, so the large copy is the one to drop: it is the
8931
- duplicate, and it was the reason the card sat under a picture the reader had
8932
- already seen. Selected from the attachment row forward rather than from the card
8933
- back, because CSS has no parent selector to reach; :has does have a following
8934
- sibling test, which is exactly this relationship.
8935
-
8936
- Two guards keep it narrow. :not([data-static]) means a card that cannot open
8937
- anything (the queue dock's, drawn before sending) hides nothing — that copy is the
8938
- only picture in the dock. And if a shell ever stops marking its attachment row
8939
- with data-message-attachments, this rule stops matching rather than breaking
8940
- anything: the large picture simply returns and the card keeps working. */
8941
- [data-message-attachments]:has(~ * .da-card:not([data-static])){display:none}
8976
+ /* Attachments belong to the message. A review may carry several PDF pages,
8977
+ and a message may also carry unrelated files; its source is not that snapshot. */
8978
+ .da-card-actions{display:flex;flex-wrap:wrap;align-items:center;justify-content:space-between;gap:4px;padding:3px 5px;
8979
+ border-top:0.5px solid var(--dsw-alias-border-l2,rgba(128,128,128,.2))}
8980
+ .da-card-toggle,.da-card-source{border:0;border-radius:6px;background:none;padding:6px 4px;min-height:30px;font-size:11px!important}
8981
+ .da-card-source{color:var(--da-accent,#176df2)!important}
8982
+ .da-card-error{padding:6px 9px;color:var(--dsw-alias-label-error,#c0392b);font-size:11px}
8983
+ .da-card-locator{margin-left:22px;overflow-wrap:anywhere;white-space:pre-wrap;color:var(--dsw-alias-label-tertiary,inherit);
8984
+ font:10px/1.5 var(--dsw-font-family-mono,ui-monospace,SFMono-Regular,Menlo,monospace)}
8985
+ .da-card[data-expanded="true"] .da-card-said{display:block;white-space:pre-wrap;-webkit-line-clamp:unset}
8986
+ .da-card[data-expanded="true"] .da-card-name{white-space:normal;overflow-wrap:anywhere}
8987
+ .da-card-target{overflow-wrap:anywhere}
8988
+ .da-staged-type{font-size:9px;font-weight:650;letter-spacing:.04em;color:var(--dsw-alias-label-tertiary,inherit)}
8989
+ .da-staged-details{display:block;width:min(440px,100%);margin-top:4px}
8990
+ .da-staged-details[hidden]{display:none}
8991
+ .da-staged-send{border:0;border-radius:6px;background:none;min-height:28px;padding:4px 8px;font-size:11px;color:var(--da-accent,#176df2)}
8992
+ .da-staged-send[disabled]{opacity:.6;cursor:default}
8942
8993
 
8943
8994
  /* --- local service browser ------------------------------------------------ */
8944
8995
  .da-service-shell{display:flex;flex-direction:column;height:100%;min-height:0}
@@ -8990,7 +9041,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
8990
9041
  .da-services{--da-service-ink:var(--dsw-alias-label-primary,#1b1d20);
8991
9042
  --da-service-muted:var(--dsw-alias-label-tertiary,#787d85);
8992
9043
  --da-service-line:color-mix(in srgb,var(--dsw-alias-border-l2,#c9cbd0) 66%,transparent);
8993
- box-sizing:border-box;height:100%;overflow:auto;padding:clamp(26px,7vh,64px) 22px 40px;
9044
+ box-sizing:border-box;height:100%;overflow:auto;padding:24px 20px 32px;width:100%;
8994
9045
  color:var(--da-service-ink);
8995
9046
  background:
8996
9047
  radial-gradient(circle at 18% 0%,color-mix(in srgb,#4c8a63 8%,transparent),transparent 36%),
@@ -8998,10 +9049,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
8998
9049
  .da-services-head,.da-service-search,.da-services-toolbar,.da-service-list,.da-services-error{
8999
9050
  width:min(480px,100%);margin-left:auto;margin-right:auto}
9000
9051
  .da-services-head{padding:0 4px 18px}
9001
- .da-services-kicker{display:block;margin-bottom:8px;color:var(--da-service-muted);
9002
- font:9px/1.2 var(--dsw-font-family-mono,ui-monospace,SFMono-Regular,Menlo,monospace);
9003
- letter-spacing:.16em}
9004
- .da-services-head h1{margin:0;font-size:clamp(25px,4.5vw,34px);font-weight:680;line-height:1.08;letter-spacing:-.035em}
9052
+ .da-services-head h1{margin:0;font-size:20px;font-weight:680;line-height:1.08;letter-spacing:-.035em}
9005
9053
  .da-services-head p{max-width:410px;margin:9px 0 0;color:var(--da-service-muted);font-size:12px;line-height:1.65}
9006
9054
  .da-service-search{display:flex;align-items:center;gap:9px;box-sizing:border-box;height:48px;padding:0 7px 0 14px;
9007
9055
  border:1px solid var(--da-service-line);border-radius:15px;
@@ -9456,6 +9504,46 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
9456
9504
  ]
9457
9505
  });
9458
9506
  }
9507
+ /** Put one more copy beside the existing sheet. */
9508
+ function CopyIcon() {
9509
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
9510
+ className: "da-ico",
9511
+ viewBox: "0 0 24 24",
9512
+ "aria-hidden": "true",
9513
+ focusable: "false",
9514
+ children: [
9515
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
9516
+ x: "8",
9517
+ y: "8",
9518
+ width: "11.5",
9519
+ height: "11.5",
9520
+ rx: "2.2"
9521
+ }),
9522
+ /* @__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" }),
9523
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M13.75 11.3v5M11.25 13.8h5" })
9524
+ ]
9525
+ });
9526
+ }
9527
+ /** Save a file out of the editor. */
9528
+ function DownloadIcon() {
9529
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
9530
+ className: "da-ico",
9531
+ viewBox: "0 0 24 24",
9532
+ "aria-hidden": "true",
9533
+ focusable: "false",
9534
+ 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" })]
9535
+ });
9536
+ }
9537
+ /** Dismiss the editor without suggesting that the draft should be saved. */
9538
+ function CloseIcon() {
9539
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
9540
+ className: "da-ico",
9541
+ viewBox: "0 0 24 24",
9542
+ "aria-hidden": "true",
9543
+ focusable: "false",
9544
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "M6.5 6.5l11 11M17.5 6.5l-11 11" })
9545
+ });
9546
+ }
9459
9547
  /** A step back, as the usual curved arrow. */
9460
9548
  function UndoIcon() {
9461
9549
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("svg", {
@@ -9539,7 +9627,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
9539
9627
  */
9540
9628
  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
9629
  /** Tools in toolbar order. */
9542
- const TOOLS = [
9630
+ const TOOLS$1 = [
9543
9631
  {
9544
9632
  tool: "select",
9545
9633
  label: "toolSelect"
@@ -15079,7 +15167,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15079
15167
  stripRef: paletteRef,
15080
15168
  style: paletteStyle,
15081
15169
  handle: paletteHandle,
15082
- tools: isVector || isHtml ? TOOLS : TOOLS.filter((entry) => !ELEMENT_TOOLS.has(entry.tool)),
15170
+ tools: isVector || isHtml ? TOOLS$1 : TOOLS$1.filter((entry) => !ELEMENT_TOOLS.has(entry.tool)),
15083
15171
  tool,
15084
15172
  elementGuide: tool === "element" ? {
15085
15173
  label: pickLevel === void 0 ? `${t("toolElementMode")}, ${t("toolElementHint")}, ${t("toolElementKeys")}` : `${t("toolElementMode")}, ${t("toolElementDepth", {
@@ -15511,6 +15599,32 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15511
15599
  });
15512
15600
  }
15513
15601
  //#endregion
15602
+ //#region src/client/annotation-subject.ts
15603
+ /** Shared display identity for a review, before and after sending. */
15604
+ /**
15605
+ * Identify the reviewed source, independently of its mark tools or screenshot.
15606
+ * @param data - the immutable message facts.
15607
+ * @returns a readable name, media badge, and whether navigation belongs to the service tab.
15608
+ */
15609
+ function annotationSubject(data) {
15610
+ const address = data.html ? loopbackAddressOf(data.file) : void 0;
15611
+ if (address !== void 0) {
15612
+ const url = new URL(address);
15613
+ return {
15614
+ name: `${url.host}${url.pathname === "/" ? "" : url.pathname}`,
15615
+ badge: "WEB",
15616
+ live: true
15617
+ };
15618
+ }
15619
+ const suffix = suffixOf(data.file);
15620
+ const badge = data.html ? "HTML" : /^(?:pdf|svg|png|jpe?g|webp|gif|bmp|ico)$/u.test(suffix) ? suffix.toUpperCase() : "FIG";
15621
+ return {
15622
+ name: baseNameOf(data.file),
15623
+ badge,
15624
+ live: false
15625
+ };
15626
+ }
15627
+ //#endregion
15514
15628
  //#region src/client/annotation-card.tsx
15515
15629
  /**
15516
15630
  * The annotation a reader sent, as a card inside their own bubble.
@@ -15518,8 +15632,8 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15518
15632
  * The message still says everything it said — the model reads the same text,
15519
15633
  * copying it copies the same text — but in the transcript the same block is easier
15520
15634
  * to scan as a small sheet: which figure, how large, how many marks, and what each
15521
- * of them asked for. Clicking it opens the figure in the Sidebar, where the full
15522
- * annotator (and every mark, at full size) already is.
15635
+ * of them asked for. Clicking its header expands the delivered review; a separate action opens the
15636
+ * current source in the Sidebar, which may have changed since this message was sent.
15523
15637
  *
15524
15638
  * Two rules keep this a view and nothing more. It reads the block it was dispatched
15525
15639
  * for and never edits it; and it draws from the parsed data alone, so a block whose
@@ -15645,9 +15759,10 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15645
15759
  * @returns the row.
15646
15760
  */
15647
15761
  function CardRow(props) {
15648
- const { mark, ordinal, page } = props;
15762
+ const { mark, ordinal, page, expanded } = props;
15649
15763
  const said = mark.comment === "" ? void 0 : mark.comment;
15650
15764
  const parts = readCardMarkLabel(mark.label);
15765
+ const number = /\d+$/u.exec(parts.name)?.[0] ?? String(ordinal);
15651
15766
  const kind = parts.name.replace(/\s*\d+$/u, "");
15652
15767
  const label = parts.element === "" ? kind : parts.element;
15653
15768
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
@@ -15660,7 +15775,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15660
15775
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15661
15776
  className: "da-card-kind",
15662
15777
  "aria-hidden": "true",
15663
- children: ordinal
15778
+ children: number
15664
15779
  }),
15665
15780
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15666
15781
  className: "da-card-mark",
@@ -15689,6 +15804,10 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15689
15804
  className: "da-card-said",
15690
15805
  children: said
15691
15806
  }),
15807
+ expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15808
+ className: "da-card-locator",
15809
+ children: mark.label
15810
+ }) : null,
15692
15811
  parts.aside === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15693
15812
  className: "da-card-warning",
15694
15813
  children: parts.aside
@@ -15702,7 +15821,6 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15702
15821
  * @returns the card, or the block's own text when its data is not one it can draw.
15703
15822
  */
15704
15823
  function AnnotationCard(props) {
15705
- const [shot, setShot] = (0, react.useState)(true);
15706
15824
  (0, react.useEffect)(() => {
15707
15825
  ensureStyles(document);
15708
15826
  }, []);
@@ -15712,111 +15830,156 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15712
15830
  className: "da-card-fallback",
15713
15831
  children: raw
15714
15832
  });
15833
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ReviewCard, {
15834
+ ...props,
15835
+ data,
15836
+ raw
15837
+ }, raw || JSON.stringify(data));
15838
+ }
15839
+ /** The review snapshot; navigation to the current source is a separate action. */
15840
+ function ReviewCard(props) {
15841
+ const { data, raw, embedded = false } = props;
15842
+ const [failedThumb, setFailedThumb] = (0, react.useState)();
15843
+ const [expanded, setExpanded] = (0, react.useState)(embedded);
15844
+ const [trouble, setTrouble] = (0, react.useState)(false);
15845
+ const bodyId = (0, react.useId)();
15715
15846
  const interactive = props.interactive !== false;
15716
15847
  const open = props.open;
15717
- const thumb = shot ? thumbFor(raw) : void 0;
15718
- const shown = data.marks.slice(0, MAX_ROWS);
15848
+ const candidate = thumbFor(raw);
15849
+ const thumb = candidate === failedThumb ? void 0 : candidate;
15850
+ const shown = embedded || expanded ? data.marks : data.marks.slice(0, MAX_ROWS);
15719
15851
  const rest = data.marks.length - shown.length;
15720
- const liveFile = data.html && loopbackAddressOf(data.file) !== void 0;
15721
- const displayFile = liveFile ? data.file.replace(/[?#].*$/u, "") : data.file;
15852
+ const subject = annotationSubject(data);
15853
+ const liveFile = subject.live;
15722
15854
  const captureNotice = liveFile && thumb === void 0 ? data.pageCapture === "omitted" ? resolveTranslate(props, "cardCaptureOmitted") : data.pageCapture === "failed" ? resolveTranslate(props, "cardCaptureFailed") : "" : "";
15723
- const canOpen = interactive && open !== void 0 && (props.canOpen?.(data.file, data.html) ?? true);
15724
- const cut = Math.max(displayFile.lastIndexOf("/"), displayFile.lastIndexOf("\\"));
15725
- const name = displayFile.slice(cut + 1);
15855
+ const sourceOpener = interactive && (props.canOpen?.(data.file, data.html) ?? true) ? open : void 0;
15726
15856
  const dimensions = /(\d+)\s*[×x]\s*(\d+)/u.exec(data.scope);
15727
15857
  const size = dimensions === null ? "" : `${dimensions[1]}×${dimensions[2]}`;
15728
- const head = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15729
- className: "da-card-head",
15730
- children: [
15731
- /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15732
- className: "da-card-shot",
15733
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SheetIcon, {}), thumb === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
15734
- alt: "",
15735
- src: thumb,
15736
- onError: () => {
15737
- setShot(false);
15738
- }
15858
+ const pages = /^(?:共 (\d+) 页|(\d+) pages)(?:,|,|$)/u.exec(data.scope);
15859
+ const scope = pages === null ? size : resolveTranslate(props, "cardPages", { n: Number(pages[1] ?? pages[2]) });
15860
+ const headContent = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
15861
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15862
+ className: "da-card-shot",
15863
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(SheetIcon, {}), thumb === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
15864
+ alt: "",
15865
+ src: thumb,
15866
+ onError: () => {
15867
+ setFailedThumb(thumb);
15868
+ }
15869
+ })]
15870
+ }),
15871
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15872
+ className: "da-card-text",
15873
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15874
+ className: "da-card-title-row",
15875
+ children: [
15876
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15877
+ className: "da-card-title",
15878
+ title: data.file,
15879
+ children: subject.name
15880
+ }),
15881
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15882
+ className: "da-card-type",
15883
+ children: subject.badge
15884
+ }),
15885
+ captureNotice === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15886
+ className: "da-card-capture",
15887
+ children: captureNotice
15888
+ })
15889
+ ]
15890
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15891
+ className: "da-card-scope",
15892
+ children: [scope === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15893
+ className: "da-card-meta",
15894
+ title: data.scope,
15895
+ children: scope
15896
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15897
+ className: "da-card-tally",
15898
+ "aria-label": resolveTranslate(props, "cardCount", { n: data.marks.length }),
15899
+ title: data.scope,
15900
+ children: data.marks.length
15739
15901
  })]
15902
+ })]
15903
+ }),
15904
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15905
+ className: "da-card-go",
15906
+ "aria-hidden": "true",
15907
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
15908
+ viewBox: "0 0 16 16",
15909
+ focusable: "false",
15910
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 3.5 4.5 4.5L6 12.5" })
15911
+ })
15912
+ })
15913
+ ] });
15914
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15915
+ className: "da-card",
15916
+ "data-expanded": expanded,
15917
+ children: [
15918
+ embedded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15919
+ className: "da-card-head da-card-head-static",
15920
+ children: headContent
15921
+ }) : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
15922
+ type: "button",
15923
+ className: "da-card-head",
15924
+ "aria-expanded": expanded,
15925
+ "aria-controls": bodyId,
15926
+ title: resolveTranslate(props, expanded ? "cardCollapse" : "cardExpand"),
15927
+ onClick: () => {
15928
+ setExpanded((value) => !value);
15929
+ },
15930
+ children: headContent
15740
15931
  }),
15741
15932
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15742
- className: "da-card-text",
15743
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15744
- className: "da-card-title-row",
15745
- children: [
15746
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15747
- className: "da-card-title",
15748
- children: name
15749
- }),
15750
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15751
- className: "da-card-type",
15752
- children: data.html ? "HTML" : "FIG"
15753
- }),
15754
- captureNotice === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15755
- className: "da-card-capture",
15756
- children: captureNotice
15757
- })
15758
- ]
15759
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15760
- className: "da-card-scope",
15761
- children: [size === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15762
- className: "da-card-meta",
15763
- title: data.scope,
15764
- children: size
15765
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15766
- className: "da-card-tally",
15767
- "aria-label": resolveTranslate(props, "cardCount", { n: data.marks.length }),
15768
- title: data.scope,
15769
- children: data.marks.length
15770
- })]
15933
+ className: "da-card-body",
15934
+ id: bodyId,
15935
+ children: [
15936
+ shown.map((mark, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardRow, {
15937
+ mark,
15938
+ ordinal: index + 1,
15939
+ expanded,
15940
+ page: mark.page === void 0 ? void 0 : resolveTranslate(props, "cardPage", { n: mark.page })
15941
+ }, `${String(mark.page ?? "")}-${String(index)}`)),
15942
+ rest > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15943
+ className: "da-card-rest",
15944
+ children: resolveTranslate(props, "cardMore", { n: rest })
15945
+ }) : null,
15946
+ data.target === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15947
+ className: "da-card-row da-card-target",
15948
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15949
+ className: "da-card-goal",
15950
+ children: resolveTranslate(props, "cardGoal")
15951
+ }), data.target]
15952
+ })
15953
+ ]
15954
+ }),
15955
+ embedded ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15956
+ className: "da-card-actions",
15957
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
15958
+ type: "button",
15959
+ className: "da-card-toggle",
15960
+ "aria-expanded": expanded,
15961
+ "aria-controls": bodyId,
15962
+ onClick: () => {
15963
+ setExpanded((value) => !value);
15964
+ },
15965
+ children: resolveTranslate(props, expanded ? "cardCollapse" : "cardExpand")
15966
+ }), sourceOpener === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
15967
+ type: "button",
15968
+ className: "da-card-source",
15969
+ title: data.file,
15970
+ onClick: () => {
15971
+ setTrouble(sourceOpener(data.file, data.html) === false);
15972
+ },
15973
+ children: resolveTranslate(props, liveFile ? "cardOpenLive" : "cardOpen")
15771
15974
  })]
15772
15975
  }),
15773
- canOpen ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15774
- className: "da-card-go",
15775
- "aria-hidden": "true",
15776
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
15777
- viewBox: "0 0 16 16",
15778
- focusable: "false",
15779
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: "m6 3.5 4.5 4.5L6 12.5" })
15780
- })
15976
+ trouble ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15977
+ className: "da-card-error",
15978
+ role: "status",
15979
+ children: resolveTranslate(props, "cardOpenFailed")
15781
15980
  }) : null
15782
15981
  ]
15783
15982
  });
15784
- const body = /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15785
- className: "da-card-body",
15786
- children: [
15787
- shown.map((mark, index) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CardRow, {
15788
- mark,
15789
- ordinal: index + 1,
15790
- page: mark.page === void 0 ? void 0 : resolveTranslate(props, "cardPage", { n: mark.page })
15791
- }, `${String(mark.page ?? "")}-${String(index)}`)),
15792
- rest > 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15793
- className: "da-card-row da-card-rest",
15794
- children: resolveTranslate(props, "cardMore", { n: rest })
15795
- }) : null,
15796
- data.target === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15797
- className: "da-card-row da-card-target",
15798
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
15799
- className: "da-card-goal",
15800
- children: resolveTranslate(props, "cardGoal")
15801
- }), data.target]
15802
- })
15803
- ]
15804
- });
15805
- const onClick = canOpen && open !== void 0 ? () => {
15806
- open(data.file, data.html);
15807
- } : void 0;
15808
- if (onClick === void 0) return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
15809
- className: "da-card",
15810
- "data-static": "",
15811
- children: [head, body]
15812
- });
15813
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
15814
- type: "button",
15815
- className: "da-card",
15816
- title: `${resolveTranslate(props, liveFile ? "cardOpenLive" : "cardOpen")} — ${displayFile}`,
15817
- onClick,
15818
- children: [head, body]
15819
- });
15820
15983
  }
15821
15984
  //#endregion
15822
15985
  //#region src/client/document-seat.ts
@@ -15900,7 +16063,8 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
15900
16063
  paths: saved,
15901
16064
  thumb,
15902
16065
  file: void 0,
15903
- pageImages: images.length,
16066
+ files: images.slice(0, 8).map(({ page, image }) => new File([image], `${baseNameOf(document.figure.path)}${pageFileSuffix(page)}.annotated.png`, { type: "image/png" })),
16067
+ pageImages: Math.min(images.length, 8),
15904
16068
  locale: options.locale,
15905
16069
  send: async () => {
15906
16070
  if (sessions === void 0) throw new Error("the sessions service is unavailable");
@@ -16119,31 +16283,9 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
16119
16283
  //#endregion
16120
16284
  //#region src/client/pending-card.tsx
16121
16285
  /**
16122
- * The parked annotation, drawn inside the composer as an attachment card.
16123
- *
16124
- * The composer is a lexical editor owned by the conversation package: nothing can be
16125
- * written into it that stays a card, and the whole annotation block sitting in the
16126
- * input box is exactly what a reader cannot scan. So the block is armed beside the box
16127
- * — its message held, its picture uploaded — and the chip stands beside the reader's
16128
- * own attachments as the face of what that send will carry: the figure's thumbnail, how
16129
- * many marks it carries, the file's name, and the first mark's note.
16130
- * It is portalled into the composer card's own flow (see `composer-seat`), so it
16131
- * sits where an attached picture sits; the dock entry that owns it holds only the
16132
- * anchor, and falls back to drawing in the dock itself when there is no card.
16133
- *
16134
- * What the card shows and what it hides is the point. A dimension or a pair of
16135
- * coordinates is not something to read at a glance, so it stays in the block the model
16136
- * reads and in the hover title, and never on the face: the face says the figure and the
16137
- * count first, the note second, and truncates both rather than wrapping into a wall of
16138
- * text.
16139
- *
16140
- * One press is the point, and the box keeps the reader's own words while it is not
16141
- * pressed: nothing is written into the draft, so the sentence being typed stays the only
16142
- * text in it. The box's own send carries the armed message — typing above the card and
16143
- * pressing Send sends the sentence and the review as one message, with the annotation
16144
- * behind the sentence — and the card's own action runs that same send for a reader who
16145
- * wants to fire it from the card. The card goes when that send takes it, or when the
16146
- * reader drops it, because that is when it stops standing for anything.
16286
+ * Reviews staged beside the composer. The chip previews its immutable payload;
16287
+ * the native composer Send carries armed reviews, with or without typed text.
16288
+ * Older hosts without draft admission retain a direct-delivery fallback.
16147
16289
  * @module dsh-figmark/client/pending-card
16148
16290
  */
16149
16291
  /**
@@ -16181,28 +16323,6 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
16181
16323
  });
16182
16324
  }
16183
16325
  /**
16184
- * The small outline that stands for a mark's shape on the card's one line.
16185
- * @returns the glyph.
16186
- */
16187
- function MarkIcon() {
16188
- return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
16189
- className: "da-staged-mark-icon",
16190
- viewBox: "0 0 12 12",
16191
- "aria-hidden": "true",
16192
- focusable: "false",
16193
- children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("rect", {
16194
- x: "1",
16195
- y: "2",
16196
- width: "10",
16197
- height: "8",
16198
- rx: "1.5",
16199
- fill: "none",
16200
- stroke: "currentColor",
16201
- strokeWidth: "1.1"
16202
- })
16203
- });
16204
- }
16205
- /**
16206
16326
  * A mark's name without its coordinates: the kind and its number, which is what a
16207
16327
  * reader recognises, with a space between the two the way the product writes it.
16208
16328
  * @param label - the mark's own name, coordinates and all.
@@ -16221,25 +16341,15 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
16221
16341
  const [busy, setBusy] = (0, react.useState)(false);
16222
16342
  const [trouble, setTrouble] = (0, react.useState)("");
16223
16343
  const [shot, setShot] = (0, react.useState)(true);
16344
+ const [expanded, setExpanded] = (0, react.useState)(false);
16345
+ const detailId = (0, react.useId)();
16224
16346
  const sending = (0, react.useRef)(false);
16225
16347
  const armed = entry.armed;
16226
16348
  const data = entry.data;
16227
16349
  const thumb = shot ? thumbFor(entry.raw) : void 0;
16228
16350
  const send = async () => {
16229
16351
  if (sending.current) return;
16230
- if (armed !== void 0) {
16231
- sending.current = true;
16232
- setBusy(true);
16233
- try {
16234
- armed.shell.submit?.();
16235
- } finally {
16236
- queueMicrotask(() => {
16237
- sending.current = false;
16238
- setBusy(false);
16239
- });
16240
- }
16241
- return;
16242
- }
16352
+ setTrouble("");
16243
16353
  sending.current = true;
16244
16354
  setBusy(true);
16245
16355
  try {
@@ -16263,88 +16373,127 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
16263
16373
  entry.id,
16264
16374
  sessionId
16265
16375
  ]);
16266
- const shownFile = data.file.replace(/[?#].*$/u, "");
16267
- const cut = Math.max(shownFile.lastIndexOf("/"), shownFile.lastIndexOf("\\"));
16268
- const name = shownFile.slice(cut + 1);
16376
+ const subject = annotationSubject(data);
16377
+ const name = subject.name;
16269
16378
  const first = data.marks[0];
16270
- const captureNotice = data.html && thumb === void 0 ? data.pageCapture === "omitted" ? resolveTranslate(props, "cardCaptureOmitted") : data.pageCapture === "failed" ? resolveTranslate(props, "cardCaptureFailed") : "" : "";
16379
+ const captureNotice = subject.live && thumb === void 0 ? data.pageCapture === "omitted" ? resolveTranslate(props, "cardCaptureOmitted") : data.pageCapture === "failed" ? resolveTranslate(props, "cardCaptureFailed") : "" : "";
16271
16380
  const detail = first === void 0 ? name : first.comment === "" ? first.label : `${first.label} · ${first.comment}`;
16272
16381
  const hover = data.scope === "" ? data.file : `${data.file} · ${data.scope}`;
16273
16382
  const removeLabel = resolveTranslate(props, "pendingRemove");
16383
+ const hint = resolveTranslate(props, "pendingSendNow");
16274
16384
  return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16275
16385
  className: "da-staged",
16276
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16277
- className: "da-staged-card",
16278
- title: hover,
16279
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
16280
- type: "button",
16281
- className: "da-staged-open",
16282
- disabled: busy,
16283
- title: resolveTranslate(props, armed === void 0 ? "pendingSendNow" : "pendingSendHint"),
16284
- onClick: () => {
16285
- send();
16286
- },
16287
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16288
- className: "da-staged-shot",
16289
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FigureIcon, {}), thumb === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
16290
- alt: "",
16291
- src: thumb,
16292
- onError: () => {
16293
- setShot(false);
16294
- }
16295
- })]
16296
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16297
- className: "da-staged-text",
16386
+ children: [
16387
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16388
+ className: "da-staged-card",
16389
+ title: hover,
16390
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
16391
+ type: "button",
16392
+ className: "da-staged-open",
16393
+ "aria-expanded": expanded,
16394
+ "aria-controls": detailId,
16395
+ title: resolveTranslate(props, expanded ? "cardCollapse" : "cardExpand"),
16396
+ onClick: () => {
16397
+ setExpanded((value) => !value);
16398
+ },
16298
16399
  children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16299
- className: "da-staged-head",
16400
+ className: "da-staged-shot",
16401
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(FigureIcon, {}), thumb === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
16402
+ alt: "",
16403
+ src: thumb,
16404
+ onError: () => {
16405
+ setShot(false);
16406
+ }
16407
+ })]
16408
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16409
+ className: "da-staged-text",
16300
16410
  children: [
16301
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16302
- className: "da-staged-title",
16303
- title: name,
16304
- children: name
16305
- }),
16306
- captureNotice === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16307
- className: "da-staged-capture",
16308
- children: captureNotice
16411
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16412
+ className: "da-staged-head",
16413
+ children: [
16414
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16415
+ className: "da-staged-title",
16416
+ title: name,
16417
+ children: name
16418
+ }),
16419
+ captureNotice === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16420
+ className: "da-staged-capture",
16421
+ children: captureNotice
16422
+ }),
16423
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16424
+ className: "da-staged-count",
16425
+ "aria-label": resolveTranslate(props, "cardCount", { n: data.marks.length }),
16426
+ children: resolveTranslate(props, "pendingCount", { n: data.marks.length })
16427
+ })
16428
+ ]
16309
16429
  }),
16310
16430
  /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16311
- className: "da-staged-count",
16312
- "aria-label": resolveTranslate(props, "cardCount", { n: data.marks.length }),
16313
- children: resolveTranslate(props, "pendingCount", { n: data.marks.length })
16314
- })
16315
- ]
16316
- }), first === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16317
- className: "da-staged-line",
16318
- title: detail,
16319
- children: [
16320
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)(MarkIcon, {}),
16321
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16322
- className: "da-staged-shape",
16323
- children: shortLabel(first.label)
16431
+ className: "da-staged-type",
16432
+ children: subject.badge
16324
16433
  }),
16325
- first.comment === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16326
- className: "da-staged-said",
16327
- children: first.comment
16434
+ first === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
16435
+ className: "da-staged-line",
16436
+ title: detail,
16437
+ children: [
16438
+ first.page === void 0 ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16439
+ className: "da-card-page",
16440
+ children: resolveTranslate(props, "cardPage", { n: first.page })
16441
+ }),
16442
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16443
+ className: "da-staged-shape",
16444
+ children: shortLabel(first.label)
16445
+ }),
16446
+ first.comment === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16447
+ className: "da-staged-said",
16448
+ children: first.comment
16449
+ })
16450
+ ]
16328
16451
  })
16329
16452
  ]
16330
16453
  })]
16454
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
16455
+ type: "button",
16456
+ className: "da-staged-x",
16457
+ "aria-label": removeLabel,
16458
+ title: removeLabel,
16459
+ disabled: busy,
16460
+ onClick: () => {
16461
+ if (sending.current) return;
16462
+ if (armed !== void 0) disarmReview(armed, entry.conversation);
16463
+ unparkAnnotation(sessionId, entry.id);
16464
+ },
16465
+ children: "×"
16331
16466
  })]
16332
- }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
16467
+ }),
16468
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16469
+ id: detailId,
16470
+ className: "da-staged-details",
16471
+ hidden: !expanded,
16472
+ children: expanded ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(AnnotationCard, {
16473
+ node: { data },
16474
+ raw: entry.raw,
16475
+ interactive: false,
16476
+ embedded: true,
16477
+ t: props.t,
16478
+ localeId: props.localeId
16479
+ }) : null
16480
+ }),
16481
+ armed === void 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
16333
16482
  type: "button",
16334
- className: "da-staged-x",
16335
- "aria-label": removeLabel,
16336
- title: removeLabel,
16483
+ className: "da-staged-send",
16337
16484
  disabled: busy,
16485
+ title: hint,
16338
16486
  onClick: () => {
16339
- if (armed !== void 0) disarmReview(armed, entry.conversation);
16340
- unparkAnnotation(sessionId, entry.id);
16487
+ send();
16341
16488
  },
16342
- children: "×"
16343
- })]
16344
- }), trouble === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16345
- className: "da-staged-trouble",
16346
- children: trouble
16347
- })]
16489
+ children: resolveTranslate(props, busy ? "pendingSending" : "pendingSendNow")
16490
+ }) : null,
16491
+ trouble === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16492
+ className: "da-staged-trouble",
16493
+ role: "alert",
16494
+ children: trouble
16495
+ })
16496
+ ]
16348
16497
  });
16349
16498
  }
16350
16499
  /**
@@ -16379,6 +16528,11 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
16379
16528
  return mountSeat(node, setSeat);
16380
16529
  }, [sessionId, showing]);
16381
16530
  if (!showing) return null;
16531
+ const owned = entries.flatMap((entry) => entry.armed?.previewUrls ?? []).map((url) => `img[src=${JSON.stringify(url)}]`).join(",");
16532
+ const thumbnailStyle = owned === "" ? null : /* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: `
16533
+ [data-composer-card] [role="group"] > div:has(${owned}){display:none}
16534
+ [data-composer-card] > div:has(> div > [role="group"]):not(:has([role="group"] > div:not(:has(${owned})))){display:none}
16535
+ ` });
16382
16536
  const cards = /* @__PURE__ */ (0, react_jsx_runtime.jsx)(react_jsx_runtime.Fragment, { children: entries.map((entry) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ParkedChip, {
16383
16537
  entry,
16384
16538
  sessionId,
@@ -16405,11 +16559,15 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
16405
16559
  children: "×"
16406
16560
  })]
16407
16561
  });
16408
- return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16409
- className: "da-staged-anchor",
16410
- ref: anchor,
16411
- hidden: true
16412
- }), seat === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, report] }) : (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, report] }), seat)] });
16562
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [
16563
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
16564
+ className: "da-staged-anchor",
16565
+ ref: anchor,
16566
+ hidden: true
16567
+ }),
16568
+ thumbnailStyle,
16569
+ seat === null ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, report] }) : (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [cards, report] }), seat)
16570
+ ] });
16413
16571
  }
16414
16572
  //#endregion
16415
16573
  //#region src/client/live-drafts.ts
@@ -16993,13 +17151,13 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
16993
17151
  return;
16994
17152
  }
16995
17153
  const needle = query.trim().toLowerCase();
16996
- const titleMatch = visible.find((service) => service.title?.toLowerCase().includes(needle) === true);
16997
- if (titleMatch !== void 0) {
16998
- openWorkspace(titleMatch.url, titleMatch.title);
16999
- return;
17000
- }
17001
- const first = visible[0];
17002
- openWorkspace(looksLikeAddress(query) || first === void 0 ? query : first.url, looksLikeAddress(query) || first === void 0 ? void 0 : first.title);
17154
+ if (needle === "") return;
17155
+ const titleMatch = visible.some((service) => service.title?.toLowerCase().includes(needle) === true);
17156
+ if (looksLikeAddress(query) && !titleMatch) openWorkspace(query);
17157
+ else if (visible.length === 1) {
17158
+ const only = visible[0];
17159
+ openWorkspace(only.url, only.title);
17160
+ } else setError(t(visible.length === 0 ? "serviceNoMatch" : "serviceChooseMatch"));
17003
17161
  };
17004
17162
  const visible = (0, react.useMemo)(() => {
17005
17163
  const needle = query.trim().toLowerCase();
@@ -17098,14 +17256,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
17098
17256
  children: [
17099
17257
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("header", {
17100
17258
  className: "da-services-head",
17101
- children: [
17102
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
17103
- className: "da-services-kicker",
17104
- children: t("serviceKicker")
17105
- }),
17106
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", { children: t("serviceTitle") }),
17107
- /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("serviceHint") })
17108
- ]
17259
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("h1", { children: t("serviceTitle") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("p", { children: t("serviceHint") })]
17109
17260
  }),
17110
17261
  /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("form", {
17111
17262
  className: "da-service-search",
@@ -17163,7 +17314,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
17163
17314
  children: t("serviceScanning")
17164
17315
  }) : visible.length === 0 ? /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
17165
17316
  className: "da-services-empty",
17166
- children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t("serviceEmpty") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t("serviceEmptyHint") })]
17317
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("strong", { children: t(query.trim() === "" ? "serviceEmpty" : "serviceNoMatch") }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { children: t(query.trim() === "" ? "serviceEmptyHint" : "serviceSearchHint") })]
17167
17318
  }) : visible.map((service) => /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
17168
17319
  type: "button",
17169
17320
  className: "da-service-row",
@@ -17278,6 +17429,1057 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
17278
17429
  });
17279
17430
  }
17280
17431
  //#endregion
17432
+ //#region src/client/attachment-annotator/model.ts
17433
+ /** One explicitly opened image at a time; retained across sidebar remounts. */
17434
+ function createAttachmentStore() {
17435
+ let job = null;
17436
+ const listeners = /* @__PURE__ */ new Set();
17437
+ const publish = () => {
17438
+ for (const listener of listeners) listener();
17439
+ };
17440
+ return {
17441
+ getSnapshot: () => job,
17442
+ subscribe(listener) {
17443
+ listeners.add(listener);
17444
+ return () => {
17445
+ listeners.delete(listener);
17446
+ };
17447
+ },
17448
+ /** Refuse to overwrite an unfinished image, including one in another Session. */
17449
+ open(sessionId, source) {
17450
+ if (job !== null) return job.sessionId === sessionId && job.source.id === source.id;
17451
+ job = {
17452
+ sessionId,
17453
+ source,
17454
+ marks: [],
17455
+ added: null
17456
+ };
17457
+ publish();
17458
+ return true;
17459
+ },
17460
+ change(source, marks) {
17461
+ if (job === null || job.source !== source || job.marks === marks) return;
17462
+ job = {
17463
+ ...job,
17464
+ marks
17465
+ };
17466
+ publish();
17467
+ },
17468
+ rememberCopy(source, marks, id) {
17469
+ if (job === null || job.source !== source) return;
17470
+ job = {
17471
+ ...job,
17472
+ added: {
17473
+ marks,
17474
+ id
17475
+ }
17476
+ };
17477
+ publish();
17478
+ },
17479
+ clear() {
17480
+ job = null;
17481
+ publish();
17482
+ },
17483
+ dispose() {
17484
+ job = null;
17485
+ listeners.clear();
17486
+ }
17487
+ };
17488
+ }
17489
+ /** Submission phases never accept a result; an attachment sent or removed is never resurrected. */
17490
+ function canAttach(job, sessionId, input) {
17491
+ return sessionId === job.sessionId && input.attachmentIds.includes(job.source.id) && (input.phase === "plain" || input.phase === "claimed");
17492
+ }
17493
+ /**
17494
+ * The text that keeps each numbered comment searchable after the flattened image
17495
+ * is staged. The image and the list carry the same ordinals, so the model can
17496
+ * connect a sentence to the mark it describes without relying on OCR.
17497
+ */
17498
+ function attachmentMessage(fileName, marks) {
17499
+ const counted = /* @__PURE__ */ new Map();
17500
+ const lines = marks.map((mark) => {
17501
+ const ordinal = (counted.get(mark.kind) ?? 0) + 1;
17502
+ counted.set(mark.kind, ordinal);
17503
+ return describeMark(mark, ordinal, "zh");
17504
+ });
17505
+ return [
17506
+ `【附图批注】已附上「${fileName.trim() || "图片"}」的标注副本,共 ${marks.length} 处。请按编号逐条处理:`,
17507
+ ...lines,
17508
+ "",
17509
+ "逐条修改原图的生成源。"
17510
+ ].join("\n");
17511
+ }
17512
+ /** Stage one copy and its numbered comments without replacing the original or sending. */
17513
+ function attachCopy(job, sessionId, input, conversation, actions, file) {
17514
+ if (!canAttach(job, sessionId, input)) throw new Error("原附件已移除、已发送,或输入框正在发送。请下载批注副本。");
17515
+ 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("原附件已失效,请下载批注副本。");
17516
+ const ids = conversation.createDrafts(sessionId, [file], false).map((draft) => draft.id);
17517
+ let attached = false;
17518
+ try {
17519
+ if (ids.length !== 1 || !actions.addAttachments(ids)) throw new Error("输入框未接受副本;原附件保持不变。");
17520
+ attached = true;
17521
+ const message = attachmentMessage(job.source.file.name, job.marks);
17522
+ actions.setDraft(input.draft.trim() === "" ? message : `${input.draft}\n\n${message}`);
17523
+ return ids[0];
17524
+ } catch (error) {
17525
+ if (attached) try {
17526
+ actions.setDraft(input.draft);
17527
+ } catch {}
17528
+ for (const id of ids) conversation.releaseDraftAttachment(id);
17529
+ throw error;
17530
+ }
17531
+ }
17532
+ /** Older hosts can keep all existing Figmark features without crashing this optional entry. */
17533
+ function attachmentConversation(value) {
17534
+ if (typeof value !== "object" || value === null) return void 0;
17535
+ const candidate = value;
17536
+ return typeof candidate.resolveDraftAttachments === "function" && typeof candidate.createDrafts === "function" && typeof candidate.releaseDraftAttachment === "function" ? candidate : void 0;
17537
+ }
17538
+ //#endregion
17539
+ //#region src/client/attachment-annotator/export.ts
17540
+ /** Plain XML text; comments must never become SVG markup. */
17541
+ function escapeText(value) {
17542
+ return value.replace(/&/gu, "&amp;").replace(/</gu, "&lt;").replace(/>/gu, "&gt;");
17543
+ }
17544
+ /** Build one self-contained SVG with a readable legend below the original image. */
17545
+ function attachmentSvg(dataUrl, size, marks) {
17546
+ const layer = {
17547
+ kind: "raster",
17548
+ dataUrl,
17549
+ ...size
17550
+ };
17551
+ const paper = reviewSize(layer, marks);
17552
+ const numbers = markOrdinals(marks);
17553
+ const font = Math.max(14, Math.min(32, paper.width / 45));
17554
+ const width = Math.max(paper.width, 320);
17555
+ const columns = Math.max(10, Math.floor((width - 32) / font));
17556
+ const lines = marks.flatMap((mark) => {
17557
+ if (mark.detail === void 0 || mark.detail.trim() === "") return [];
17558
+ return `${mark.kind === "point" ? "Pin" : mark.kind} ${numbers.get(mark.id)} · ${mark.detail}`.split(/\r?\n/u).flatMap((line) => {
17559
+ const chars = Array.from(line);
17560
+ const wrapped = [];
17561
+ for (let at = 0; at < chars.length; at += columns) wrapped.push(chars.slice(at, at + columns).join(""));
17562
+ return wrapped;
17563
+ });
17564
+ });
17565
+ const height = paper.height + (lines.length === 0 ? 0 : 32 + lines.length * font * 1.5);
17566
+ return {
17567
+ 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>`,
17568
+ width,
17569
+ height,
17570
+ scale: Math.min(1, 8192 / width, 8192 / height, Math.sqrt(16e6 / (width * height)))
17571
+ };
17572
+ }
17573
+ /** Read only when exporting; the host's preview URL remains owned by the host. */
17574
+ async function exportAttachment(file, size, marks) {
17575
+ const image = attachmentSvg(await new Promise((resolve, reject) => {
17576
+ const reader = new FileReader();
17577
+ reader.onload = () => {
17578
+ resolve(reader.result);
17579
+ };
17580
+ reader.onerror = () => {
17581
+ reject(/* @__PURE__ */ new Error("无法读取原图,请重试。"));
17582
+ };
17583
+ reader.readAsDataURL(file);
17584
+ }), size, marks);
17585
+ const blob = await rasterizePng(image.svg, image.width, image.height, image.scale);
17586
+ return new File([blob], `${file.name.replace(/\.[^.]*$/u, "") || "image"}.annotated.png`, { type: "image/png" });
17587
+ }
17588
+ /** Download is explicit and does not involve any Session. */
17589
+ function downloadAttachment(file) {
17590
+ const url = URL.createObjectURL(file);
17591
+ const link = document.createElement("a");
17592
+ link.href = url;
17593
+ link.download = file.name;
17594
+ link.click();
17595
+ setTimeout(() => {
17596
+ URL.revokeObjectURL(url);
17597
+ }, 1e3);
17598
+ }
17599
+ //#endregion
17600
+ //#region src/client/attachment-annotator/styles.ts
17601
+ /** Styles for the composer-thumbnail annotation overlay. */
17602
+ const ATTACHMENT_CSS = `
17603
+ .da-attachment-anchor{display:block;width:0;height:0;overflow:hidden;pointer-events:none}
17604
+ .da-attachment{
17605
+ --da-accent:#e03131;
17606
+ --da-blue:#176df2;
17607
+ --da-ink:#242529;
17608
+ --da-muted:#74777f;
17609
+ --da-faint:#9699a1;
17610
+ --da-line:rgba(36,37,41,.11);
17611
+ --da-paper:#fff;
17612
+ --da-rail:#2b2c30;
17613
+ position:fixed;
17614
+ inset:0;
17615
+ z-index:2147483000;
17616
+ color:var(--da-ink);
17617
+ font-family:var(--dsw-font-family,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif);
17618
+ font-size:13px;
17619
+ outline:none;
17620
+ }
17621
+ .da-attachment,.da-attachment *{box-sizing:border-box}
17622
+ .da-attachment button,.da-attachment textarea,.da-attachment input{font:inherit}
17623
+ .da-attachment button{cursor:pointer}
17624
+ .da-attachment button:focus-visible,.da-attachment textarea:focus-visible,.da-attachment input:focus-visible{outline:2px solid #6ea2ff;outline-offset:2px}
17625
+ .da-attachment button[aria-disabled=true]{opacity:.38;cursor:default}
17626
+ .da-attachment-backdrop{
17627
+ position:absolute;
17628
+ inset:0;
17629
+ display:grid;
17630
+ place-items:center;
17631
+ padding:24px;
17632
+ background:rgba(18,19,22,.58);
17633
+ backdrop-filter:blur(7px);
17634
+ animation:da-attachment-fade .18s ease-out both;
17635
+ }
17636
+ .da-attachment-dialog{
17637
+ position:relative;
17638
+ isolation:isolate;
17639
+ display:grid;
17640
+ grid-template-rows:minmax(0,1fr) auto;
17641
+ width:min(1240px,calc(100vw - 32px));
17642
+ height:min(860px,calc(100vh - 32px));
17643
+ min-width:520px;
17644
+ min-height:420px;
17645
+ overflow:visible;
17646
+ background:transparent;
17647
+ animation:da-attachment-rise .22s cubic-bezier(.22,1,.36,1) both;
17648
+ }
17649
+ .da-attachment svg{fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round}
17650
+ .da-attachment-stage{
17651
+ position:relative;
17652
+ display:flex;
17653
+ min-width:0;
17654
+ min-height:0;
17655
+ overflow:auto;
17656
+ padding:34px 100px 30px 34px;
17657
+ background:transparent;
17658
+ scrollbar-width:thin;
17659
+ overscroll-behavior:contain;
17660
+ }
17661
+ .da-attachment-canvas{
17662
+ position:relative;
17663
+ flex:0 0 auto;
17664
+ margin:auto;
17665
+ }
17666
+ .da-attachment-paper{
17667
+ position:relative;
17668
+ transform-origin:top left;
17669
+ overflow:visible;
17670
+ background:transparent;
17671
+ }
17672
+ .da-attachment-paper[data-busy=true]{cursor:progress}
17673
+ .da-attachment-paper>img{position:absolute;inset:0;display:block;width:100%;height:100%;border-radius:inherit;pointer-events:none;user-select:none}
17674
+ .da-attachment-paper>.da-overlay{position:absolute;inset:0;display:block;width:100%;height:100%;overflow:visible;touch-action:none}
17675
+ .da-attachment-paper>.da-overlay[data-tool=select] [data-da-mark]{cursor:move}
17676
+ .da-attachment-rail{
17677
+ position:absolute;
17678
+ z-index:20;
17679
+ top:50%;
17680
+ right:16px;
17681
+ display:flex;
17682
+ flex-direction:column;
17683
+ align-items:center;
17684
+ gap:3px;
17685
+ width:48px;
17686
+ max-height:calc(100% - 34px);
17687
+ overflow-y:auto;
17688
+ padding:7px 6px;
17689
+ transform:translateY(-50%);
17690
+ border:1px solid rgba(255,255,255,.09);
17691
+ border-radius:17px;
17692
+ background:var(--da-rail);
17693
+ color:#fff;
17694
+ box-shadow:0 12px 30px rgba(18,19,23,.3),inset 0 1px 0 rgba(255,255,255,.08);
17695
+ scrollbar-width:none;
17696
+ }
17697
+ .da-attachment-rail::-webkit-scrollbar{display:none}
17698
+ .da-attachment-close svg{width:16px;height:16px}
17699
+ .da-attachment-tool{
17700
+ display:flex;
17701
+ align-items:center;
17702
+ justify-content:center;
17703
+ width:35px;
17704
+ height:35px;
17705
+ flex:0 0 auto;
17706
+ padding:0;
17707
+ border:0;
17708
+ border-radius:10px;
17709
+ background:transparent;
17710
+ color:rgba(255,255,255,.72);
17711
+ font-size:18px;
17712
+ line-height:1;
17713
+ }
17714
+ .da-attachment-tool:hover{background:rgba(255,255,255,.1);color:#fff}
17715
+ .da-attachment-tool[aria-pressed=true]{background:rgba(255,255,255,.19);color:#fff}
17716
+ .da-attachment-tool[aria-disabled=true]:hover{background:transparent;color:rgba(255,255,255,.72)}
17717
+ .da-attachment-tool svg{width:17px;height:17px}
17718
+ .da-attachment-separator{width:22px;height:1px;flex:0 0 auto;margin:3px 0;background:rgba(255,255,255,.15)}
17719
+ .da-attachment-color{
17720
+ position:relative;
17721
+ display:flex;
17722
+ align-items:center;
17723
+ justify-content:center;
17724
+ width:35px;
17725
+ height:35px;
17726
+ flex:0 0 auto;
17727
+ border-radius:10px;
17728
+ cursor:pointer;
17729
+ }
17730
+ .da-attachment-color:hover{background:rgba(255,255,255,.1)}
17731
+ .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)}
17732
+ .da-attachment-color input{position:absolute;inset:0;width:100%;height:100%;padding:0;border:0;opacity:0;cursor:pointer}
17733
+ .da-attachment-zoom{
17734
+ display:block;
17735
+ min-width:34px;
17736
+ padding:2px 0 3px;
17737
+ color:rgba(255,255,255,.55);
17738
+ font-size:8.5px;
17739
+ font-variant-numeric:tabular-nums;
17740
+ line-height:1;
17741
+ text-align:center;
17742
+ }
17743
+ .da-attachment-footer{
17744
+ display:flex;
17745
+ flex-direction:column;
17746
+ align-items:center;
17747
+ gap:6px;
17748
+ align-self:center;
17749
+ width:auto;
17750
+ margin:0 auto 14px;
17751
+ padding:0;
17752
+ }
17753
+ .da-attachment-status{
17754
+ display:flex;
17755
+ flex-direction:column;
17756
+ align-items:center;
17757
+ gap:3px;
17758
+ max-width:min(560px,calc(100vw - 56px));
17759
+ padding:6px 10px;
17760
+ border-radius:9px;
17761
+ background:rgba(23,24,27,.82);
17762
+ color:rgba(255,255,255,.72);
17763
+ font-size:10px;
17764
+ line-height:1.4;
17765
+ text-align:center;
17766
+ backdrop-filter:blur(12px);
17767
+ }
17768
+ .da-attachment-warning{color:#9a6418}
17769
+ .da-attachment-actions{display:flex;align-items:center;gap:7px;flex:0 0 auto}
17770
+ .da-attachment-action{
17771
+ display:inline-flex;
17772
+ align-items:center;
17773
+ justify-content:center;
17774
+ gap:7px;
17775
+ height:31px;
17776
+ padding:0 11px;
17777
+ border:.5px solid rgba(255,255,255,.28);
17778
+ border-radius:999px;
17779
+ background:rgba(251,251,252,.92);
17780
+ color:#4f5157;
17781
+ font-size:10.5px;
17782
+ font-weight:580;
17783
+ line-height:1;
17784
+ white-space:nowrap;
17785
+ box-shadow:0 5px 18px rgba(5,6,9,.18);
17786
+ }
17787
+ .da-attachment-action:hover{background:#eeeef0;color:var(--da-ink)}
17788
+ .da-attachment-action svg{width:14px;height:14px}
17789
+ .da-attachment-action-primary{border-color:#26272b;background:#26272b;color:#fff;box-shadow:0 1px 2px rgba(0,0,0,.14)}
17790
+ .da-attachment-action-primary:hover{background:#3a3b40;color:#fff}
17791
+ .da-attachment-comment-pill{
17792
+ position:absolute;
17793
+ z-index:15;
17794
+ width:286px;
17795
+ max-width:calc(100% - 16px);
17796
+ padding:3px;
17797
+ border:.5px solid var(--da-line);
17798
+ border-radius:20px;
17799
+ background:color-mix(in srgb,var(--da-paper) 94%,transparent);
17800
+ color:var(--da-ink);
17801
+ box-shadow:0 5px 18px rgba(20,40,80,.14),0 0 0 .5px rgba(20,40,80,.06);
17802
+ backdrop-filter:blur(10px);
17803
+ }
17804
+ .da-attachment-comment-pill textarea{
17805
+ width:100%;
17806
+ min-height:0;
17807
+ max-height:min(180px,42vh);
17808
+ resize:none;
17809
+ padding:7px 10px;
17810
+ border:0;
17811
+ outline:0;
17812
+ background:transparent;
17813
+ color:var(--da-ink);
17814
+ font:inherit;
17815
+ font-size:13px;
17816
+ line-height:1.55;
17817
+ field-sizing:content;
17818
+ overflow-y:auto;
17819
+ scrollbar-width:none;
17820
+ }
17821
+ .da-attachment-comment-pill textarea::-webkit-scrollbar{width:0;height:0}
17822
+ .da-attachment-comment-pill textarea::placeholder{color:#a1a4ab}
17823
+ .da-attachment .da-attachment-comment-pill textarea:focus-visible{outline:0}
17824
+ .da-attachment-confirm{
17825
+ position:absolute;
17826
+ z-index:40;
17827
+ left:50%;
17828
+ bottom:82px;
17829
+ display:flex;
17830
+ align-items:center;
17831
+ justify-content:space-between;
17832
+ gap:20px;
17833
+ width:min(500px,calc(100% - 40px));
17834
+ padding:12px 12px 12px 15px;
17835
+ transform:translateX(-50%);
17836
+ border:1px solid rgba(180,35,24,.2);
17837
+ border-radius:13px;
17838
+ background:rgba(255,252,251,.98);
17839
+ box-shadow:0 14px 38px rgba(50,25,20,.18);
17840
+ }
17841
+ .da-attachment-confirm>div:first-child{display:flex;flex-direction:column;gap:2px;min-width:0}
17842
+ .da-attachment-confirm strong{color:#352f2e;font-size:11.5px;font-weight:650}
17843
+ .da-attachment-confirm span{color:#827875;font-size:9.5px;line-height:1.35}
17844
+ .da-attachment-confirm-actions{display:flex;gap:6px;flex:0 0 auto}
17845
+ .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}
17846
+ .da-attachment-confirm button:hover{background:#f1f1f2}
17847
+ .da-attachment-confirm .da-attachment-danger{border-color:rgba(180,35,24,.22);color:#a1281f}
17848
+ .da-attachment-confirm .da-attachment-danger:hover{background:#fae9e7}
17849
+ @keyframes da-attachment-fade{from{opacity:0}to{opacity:1}}
17850
+ @keyframes da-attachment-rise{from{opacity:0;transform:translateY(8px) scale(.992)}to{opacity:1;transform:none}}
17851
+ @media(max-width:760px){
17852
+ .da-attachment-backdrop{padding:0}
17853
+ .da-attachment-dialog{width:100vw;height:100vh;min-width:0;min-height:0}
17854
+ .da-attachment-stage{padding:16px 70px 18px 16px}
17855
+ .da-attachment-rail{right:9px;width:46px}
17856
+ .da-attachment-footer{gap:6px;margin-bottom:10px}
17857
+ .da-attachment-status{max-width:calc(100vw - 32px)}
17858
+ .da-attachment-actions{justify-content:flex-end}
17859
+ .da-attachment-action{flex:0 0 auto}
17860
+ .da-attachment-comment-pill{width:248px}
17861
+ }
17862
+ @media(pointer:coarse){
17863
+ .da-attachment-tool,.da-attachment-color{width:40px;height:40px}
17864
+ .da-attachment-rail{width:53px}
17865
+ }
17866
+ @media(prefers-reduced-motion:reduce){
17867
+ .da-attachment-backdrop,.da-attachment-dialog{animation:none}
17868
+ }
17869
+ `;
17870
+ //#endregion
17871
+ //#region src/client/attachment-annotator/editor.tsx
17872
+ /** A comp-access annotation overlay opened from a composer image thumbnail. */
17873
+ const TOOL_LABELS = {
17874
+ select: "选择",
17875
+ pen: "画笔",
17876
+ arrow: "箭头",
17877
+ rect: "矩形",
17878
+ ellipse: "椭圆",
17879
+ point: "Pin 评论"
17880
+ };
17881
+ const TOOLS = [
17882
+ "select",
17883
+ "pen",
17884
+ "arrow",
17885
+ "rect",
17886
+ "ellipse",
17887
+ "point"
17888
+ ].map((tool) => [tool, TOOL_LABELS[tool]]);
17889
+ const ZOOMS = [
17890
+ 1,
17891
+ 1.5,
17892
+ 2,
17893
+ 4
17894
+ ];
17895
+ const ZOOM_IN = {
17896
+ 1: 1.5,
17897
+ 1.5: 2,
17898
+ 2: 4,
17899
+ 4: 4
17900
+ };
17901
+ const ZOOM_OUT = {
17902
+ 1: 1,
17903
+ 1.5: 1,
17904
+ 2: 1.5,
17905
+ 4: 2
17906
+ };
17907
+ function PointerIcon() {
17908
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("svg", {
17909
+ viewBox: "0 0 24 24",
17910
+ "aria-hidden": "true",
17911
+ focusable: "false",
17912
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("path", { d: POINTER_PATH })
17913
+ });
17914
+ }
17915
+ function ToolGlyph({ tool }) {
17916
+ return tool === "select" ? /* @__PURE__ */ (0, react_jsx_runtime.jsx)(PointerIcon, {}) : ToolIcon(tool);
17917
+ }
17918
+ function imagesOf(conversation, attachmentIds) {
17919
+ if (conversation === void 0) return [];
17920
+ return conversation.resolveDraftAttachments(attachmentIds).filter((draft) => draft.kind === "image" && "file" in draft);
17921
+ }
17922
+ /** Where a mark's adjacent comment should be anchored, in figure pixels. */
17923
+ function attachmentFocusPoint(mark) {
17924
+ if (mark.kind === "arrow") return mark.points[mark.points.length - 1] ?? [0, 0];
17925
+ if (mark.kind === "rect" || mark.kind === "ellipse") {
17926
+ const first = mark.points[0] ?? [0, 0];
17927
+ const second = mark.points[1] ?? first;
17928
+ return [(first[0] + second[0]) / 2, (first[1] + second[1]) / 2];
17929
+ }
17930
+ if (mark.kind === "pen") {
17931
+ const points = mark.points.length === 0 ? [[0, 0]] : mark.points;
17932
+ const xs = points.map((point) => point[0]);
17933
+ const ys = points.map((point) => point[1]);
17934
+ return [(Math.min(...xs) + Math.max(...xs)) / 2, (Math.min(...ys) + Math.max(...ys)) / 2];
17935
+ }
17936
+ return mark.points[0] ?? [0, 0];
17937
+ }
17938
+ function canEditAttachment(job, sessionId, input) {
17939
+ return canAttach(job, sessionId, input);
17940
+ }
17941
+ /** Mounted once per Session. It has no visible row until a draft image is clicked. */
17942
+ function AttachmentOverlay(props) {
17943
+ const { store, sessionId, conversation, useInput } = props;
17944
+ const draft = useInput((input) => input.draft);
17945
+ const attachmentIds = useInput((input) => input.attachmentIds);
17946
+ const input = {
17947
+ draft,
17948
+ attachmentIds,
17949
+ phase: useInput((input) => input.phase)
17950
+ };
17951
+ const job = (0, react.useSyncExternalStore)(store.subscribe, store.getSnapshot);
17952
+ const images = imagesOf(conversation, attachmentIds);
17953
+ const imagesRef = (0, react.useRef)(images);
17954
+ const pendingThumbnail = (0, react.useRef)(null);
17955
+ const anchor = (0, react.useRef)(null);
17956
+ imagesRef.current = images;
17957
+ (0, react.useEffect)(() => {
17958
+ const card = anchor.current?.closest("[data-composer-card]");
17959
+ if (card === null || card === void 0) return;
17960
+ const openImage = (event) => {
17961
+ if (!(event instanceof MouseEvent)) return;
17962
+ if (event.button !== 0) return;
17963
+ const target = event.target;
17964
+ if (!(target instanceof Element)) return;
17965
+ const imageNode = target instanceof HTMLImageElement ? target : target.closest("button")?.querySelector("img") ?? null;
17966
+ if (imageNode === null) return;
17967
+ const previewUrl = imageNode.getAttribute("src");
17968
+ if (previewUrl === null) return;
17969
+ const image = imagesRef.current.find((draft) => draft.previewUrl === previewUrl);
17970
+ if (image === void 0 && imagesRef.current.length > 0) return;
17971
+ event.preventDefault();
17972
+ event.stopPropagation();
17973
+ if (image !== void 0) {
17974
+ store.open(sessionId, image);
17975
+ return;
17976
+ }
17977
+ pendingThumbnail.current = previewUrl;
17978
+ };
17979
+ card.addEventListener("click", openImage, true);
17980
+ return () => {
17981
+ card.removeEventListener("click", openImage, true);
17982
+ };
17983
+ }, [sessionId, store]);
17984
+ (0, react.useEffect)(() => {
17985
+ const previewUrl = pendingThumbnail.current;
17986
+ if (previewUrl === null) return;
17987
+ const image = images.find((draft) => draft.previewUrl === previewUrl);
17988
+ if (image === void 0) return;
17989
+ pendingThumbnail.current = null;
17990
+ store.open(sessionId, image);
17991
+ }, [
17992
+ images,
17993
+ sessionId,
17994
+ store
17995
+ ]);
17996
+ (0, react.useEffect)(() => {
17997
+ if (job === null) return;
17998
+ if (job.sessionId !== sessionId || !canEditAttachment(job, sessionId, input)) store.clear();
17999
+ }, [
18000
+ input.attachmentIds,
18001
+ input.phase,
18002
+ job,
18003
+ sessionId,
18004
+ store
18005
+ ]);
18006
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)(react_jsx_runtime.Fragment, { children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18007
+ ref: anchor,
18008
+ className: "da-attachment-anchor",
18009
+ "aria-hidden": "true"
18010
+ }), job !== null && job.sessionId === sessionId ? (0, react_dom.createPortal)(/* @__PURE__ */ (0, react_jsx_runtime.jsx)(AttachmentEditor, {
18011
+ ...props,
18012
+ job
18013
+ }, `${job.sessionId}/${job.source.id}`), document.body) : null] });
18014
+ }
18015
+ /** The whole editor is one modal surface; the host's own composer remains intact. */
18016
+ function AttachmentEditor(props) {
18017
+ const { store, sessionId, conversation, useInput, inputActions, job } = props;
18018
+ const input = {
18019
+ draft: useInput((input) => input.draft),
18020
+ attachmentIds: useInput((input) => input.attachmentIds),
18021
+ phase: useInput((input) => input.phase)
18022
+ };
18023
+ const history = useAnnotationHistory(job.marks);
18024
+ const latest = (0, react.useRef)({
18025
+ input,
18026
+ sessionId
18027
+ });
18028
+ latest.current = {
18029
+ input,
18030
+ sessionId
18031
+ };
18032
+ const mounted = (0, react.useRef)(true);
18033
+ const root = (0, react.useRef)(null);
18034
+ const paper = (0, react.useRef)(null);
18035
+ const viewport = (0, react.useRef)(null);
18036
+ const canvasInput = (0, react.useRef)(null);
18037
+ const busyRef = (0, react.useRef)(false);
18038
+ const [previewUrl, setPreviewUrl] = (0, react.useState)();
18039
+ const [tool, setTool] = (0, react.useState)("select");
18040
+ const [color, setColor] = (0, react.useState)("#e03131");
18041
+ const [selectedId, setSelectedId] = (0, react.useState)(null);
18042
+ const [size, setSize] = (0, react.useState)(null);
18043
+ const [zoom, setZoom] = (0, react.useState)(1);
18044
+ const [viewportSize, setViewportSize] = (0, react.useState)({
18045
+ width: 760,
18046
+ height: 520
18047
+ });
18048
+ const [notice, setNotice] = (0, react.useState)("");
18049
+ const [busy, setBusy] = (0, react.useState)(false);
18050
+ const [confirmClose, setConfirmClose] = (0, react.useState)(false);
18051
+ (0, react.useEffect)(() => {
18052
+ mounted.current = true;
18053
+ root.current?.focus();
18054
+ return () => {
18055
+ mounted.current = false;
18056
+ };
18057
+ }, []);
18058
+ (0, react.useEffect)(() => {
18059
+ const url = URL.createObjectURL(job.source.file);
18060
+ setPreviewUrl(url);
18061
+ return () => {
18062
+ URL.revokeObjectURL(url);
18063
+ };
18064
+ }, [job.source.file]);
18065
+ (0, react.useEffect)(() => {
18066
+ store.change(job.source, history.marks);
18067
+ }, [
18068
+ history.marks,
18069
+ job.source,
18070
+ store
18071
+ ]);
18072
+ (0, react.useEffect)(() => {
18073
+ const node = viewport.current;
18074
+ /* v8 ignore next -- the effect runs after the stage ref has attached. */
18075
+ if (node === null) return;
18076
+ const measure = () => {
18077
+ setViewportSize({
18078
+ width: Math.max(280, node.clientWidth),
18079
+ height: Math.max(240, node.clientHeight)
18080
+ });
18081
+ };
18082
+ measure();
18083
+ const observer = new ResizeObserver(measure);
18084
+ observer.observe(node);
18085
+ return () => {
18086
+ observer.disconnect();
18087
+ };
18088
+ }, []);
18089
+ 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;
18090
+ const selected = history.marks.find((mark) => mark.id === selectedId) ?? null;
18091
+ const ready = canEditAttachment(job, sessionId, input) && conversation !== void 0;
18092
+ const percentage = Math.round(zoom * 100);
18093
+ const select = (id) => {
18094
+ setSelectedId(id);
18095
+ };
18096
+ const remove = (id) => {
18097
+ history.commit(removedMarks(history.marks, id));
18098
+ setSelectedId(null);
18099
+ };
18100
+ const requestClose = () => {
18101
+ canvasInput.current?.cancel();
18102
+ if (history.marks.length === 0) store.clear();
18103
+ else setConfirmClose(true);
18104
+ };
18105
+ const finish = async (download) => {
18106
+ const liveConversation = conversation;
18107
+ if (busyRef.current || size === null || history.marks.length === 0) return;
18108
+ if (!download && (!ready || liveConversation === void 0)) return;
18109
+ canvasInput.current?.cancel();
18110
+ busyRef.current = true;
18111
+ setBusy(true);
18112
+ setNotice(download ? "正在生成 PNG…" : "正在生成标注副本…");
18113
+ try {
18114
+ const file = await exportAttachment(job.source.file, size, history.marks);
18115
+ if (!mounted.current || store.getSnapshot()?.source !== job.source) return;
18116
+ const editor = root.current;
18117
+ if (editor !== null && typeof editor.checkVisibility === "function" && !editor.checkVisibility()) throw new Error("标注浮层已关闭,请重新打开后重试");
18118
+ if (download) {
18119
+ downloadAttachment(file);
18120
+ setNotice("已下载 PNG;原图和输入框未改变。");
18121
+ return;
18122
+ }
18123
+ if (store.getSnapshot()?.marks !== history.marks) throw new Error("生成过程中批注已更改,请重新生成");
18124
+ /* v8 ignore next -- the callable form above already refused an absent Host. */
18125
+ if (liveConversation === void 0) return;
18126
+ const live = latest.current;
18127
+ const id = attachCopy(job, live.sessionId, live.input, liveConversation, inputActions, file);
18128
+ store.rememberCopy(job.source, history.marks, id);
18129
+ store.clear();
18130
+ } catch (error) {
18131
+ if (mounted.current && store.getSnapshot() !== null) setNotice(`生成或加入副本失败:${String(error)}。批注已保留,可重试或下载。`);
18132
+ } finally {
18133
+ busyRef.current = false;
18134
+ if (mounted.current && store.getSnapshot() !== null) setBusy(false);
18135
+ }
18136
+ };
18137
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("section", {
18138
+ ref: root,
18139
+ className: "da-attachment",
18140
+ tabIndex: -1,
18141
+ role: "dialog",
18142
+ "aria-modal": "true",
18143
+ "aria-label": "附图标注编辑器",
18144
+ onKeyDown: (event) => {
18145
+ event.stopPropagation();
18146
+ if (event.target instanceof HTMLTextAreaElement || event.target instanceof HTMLInputElement) return;
18147
+ if ((event.metaKey || event.ctrlKey) && event.key.toLowerCase() === "z") {
18148
+ event.preventDefault();
18149
+ if (event.shiftKey) history.redo();
18150
+ else history.undo();
18151
+ } else if (event.key === "Escape") {
18152
+ canvasInput.current?.cancel();
18153
+ setSelectedId(null);
18154
+ requestClose();
18155
+ } else if ((event.key === "Delete" || event.key === "Backspace") && selectedId !== null) {
18156
+ event.preventDefault();
18157
+ remove(selectedId);
18158
+ }
18159
+ },
18160
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("style", { children: ATTACHMENT_CSS }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18161
+ className: "da-attachment-backdrop",
18162
+ onPointerDown: (event) => {
18163
+ if (event.target === event.currentTarget) requestClose();
18164
+ },
18165
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18166
+ className: "da-attachment-dialog",
18167
+ children: [
18168
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18169
+ className: "da-attachment-stage",
18170
+ ref: viewport,
18171
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18172
+ className: "da-attachment-canvas",
18173
+ style: size === null ? {} : {
18174
+ width: size.width * scale,
18175
+ height: size.height * scale
18176
+ },
18177
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18178
+ ref: paper,
18179
+ className: "da-attachment-paper",
18180
+ "data-busy": busy || void 0,
18181
+ style: size === null ? {} : {
18182
+ width: size.width,
18183
+ height: size.height,
18184
+ transform: `scale(${scale})`
18185
+ },
18186
+ onPointerDownCapture: (event) => {
18187
+ if (!busyRef.current) return;
18188
+ event.preventDefault();
18189
+ event.stopPropagation();
18190
+ },
18191
+ children: [
18192
+ previewUrl !== void 0 && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("img", {
18193
+ src: previewUrl,
18194
+ alt: "待标注原图",
18195
+ draggable: false,
18196
+ onLoad: (event) => {
18197
+ const image = event.currentTarget;
18198
+ if (image.naturalWidth === 0 || image.naturalHeight === 0) {
18199
+ setSize(null);
18200
+ setNotice("图片尺寸无效。");
18201
+ return;
18202
+ }
18203
+ setSize({
18204
+ width: image.naturalWidth,
18205
+ height: image.naturalHeight
18206
+ });
18207
+ },
18208
+ onError: () => {
18209
+ setSize(null);
18210
+ setNotice("原图加载失败;请重新添加图片后重试。");
18211
+ }
18212
+ }),
18213
+ size !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(Canvas, {
18214
+ width: size.width,
18215
+ height: size.height,
18216
+ marks: history.marks,
18217
+ tool,
18218
+ color,
18219
+ containerRef: paper,
18220
+ inputRef: canvasInput,
18221
+ anchoring: false,
18222
+ scale,
18223
+ selectedId,
18224
+ onSelect: select,
18225
+ onAdd: (mark) => {
18226
+ if (history.marks.length >= 100) {
18227
+ setNotice("单张图片最多 100 条批注。");
18228
+ return;
18229
+ }
18230
+ history.commit([...history.marks, mark]);
18231
+ },
18232
+ onMove: (id, points, anchor) => {
18233
+ const next = movedMarks(history.marks, id, points, anchor);
18234
+ if (next !== null) history.commit(next);
18235
+ },
18236
+ onUpdate: (id, patch) => {
18237
+ history.commit(patchedMarks(history.marks, id, patch));
18238
+ },
18239
+ onRemove: remove,
18240
+ onEdit: (id) => {
18241
+ setSelectedId(id);
18242
+ setTool("select");
18243
+ },
18244
+ onDrawn: (id) => {
18245
+ setSelectedId(id);
18246
+ if (tool !== "pen") setTool("select");
18247
+ }
18248
+ }),
18249
+ selected !== null && size !== null && /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CommentPill, {
18250
+ mark: selected,
18251
+ size,
18252
+ scale,
18253
+ onChange: (value) => {
18254
+ history.amend((marks) => detailedMarks(marks, selected.id, value));
18255
+ },
18256
+ onClose: () => {
18257
+ setSelectedId(null);
18258
+ }
18259
+ })
18260
+ ]
18261
+ })
18262
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18263
+ className: "da-attachment-rail",
18264
+ role: "toolbar",
18265
+ "aria-label": "绘图工具",
18266
+ children: [
18267
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18268
+ type: "button",
18269
+ className: "da-attachment-tool da-attachment-close",
18270
+ "aria-label": "关闭附图标注",
18271
+ title: "关闭",
18272
+ onClick: requestClose,
18273
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(CloseIcon, {})
18274
+ }),
18275
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "da-attachment-separator" }),
18276
+ TOOLS.map(([value, label]) => /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18277
+ type: "button",
18278
+ className: "da-attachment-tool",
18279
+ "aria-label": label,
18280
+ title: label,
18281
+ "aria-pressed": tool === value,
18282
+ onClick: () => {
18283
+ canvasInput.current?.cancel();
18284
+ setTool(value);
18285
+ },
18286
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(ToolGlyph, { tool: value })
18287
+ }, value)),
18288
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "da-attachment-separator" }),
18289
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("label", {
18290
+ className: "da-attachment-color",
18291
+ title: "批注颜色",
18292
+ style: { "--da-accent": color },
18293
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("input", {
18294
+ type: "color",
18295
+ "aria-label": "批注颜色",
18296
+ value: color,
18297
+ onChange: (event) => {
18298
+ setColor(event.target.value);
18299
+ if (selectedId !== null) history.commit(patchedMarks(history.marks, selectedId, { color: event.target.value }));
18300
+ }
18301
+ })
18302
+ }),
18303
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18304
+ type: "button",
18305
+ className: "da-attachment-tool",
18306
+ "aria-label": "撤销",
18307
+ title: "撤销",
18308
+ "aria-disabled": history.depth.past === 0,
18309
+ onClick: history.undo,
18310
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(UndoIcon, {})
18311
+ }),
18312
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18313
+ type: "button",
18314
+ className: "da-attachment-tool",
18315
+ "aria-label": "重做",
18316
+ title: "重做",
18317
+ "aria-disabled": history.depth.future === 0,
18318
+ onClick: history.redo,
18319
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)(RedoIcon, {})
18320
+ }),
18321
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", { className: "da-attachment-separator" }),
18322
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18323
+ type: "button",
18324
+ className: "da-attachment-tool",
18325
+ "aria-label": "缩小",
18326
+ title: "缩小",
18327
+ "aria-disabled": zoom === ZOOMS[0],
18328
+ onClick: () => {
18329
+ setZoom(ZOOM_OUT[zoom]);
18330
+ },
18331
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18332
+ "aria-hidden": "true",
18333
+ children: "−"
18334
+ })
18335
+ }),
18336
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("span", {
18337
+ className: "da-attachment-zoom",
18338
+ "aria-label": "当前缩放",
18339
+ children: [percentage, "%"]
18340
+ }),
18341
+ /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18342
+ type: "button",
18343
+ className: "da-attachment-tool",
18344
+ "aria-label": "放大",
18345
+ title: "放大",
18346
+ "aria-disabled": zoom === ZOOMS[ZOOMS.length - 1],
18347
+ onClick: () => {
18348
+ setZoom(ZOOM_IN[zoom]);
18349
+ },
18350
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18351
+ "aria-hidden": "true",
18352
+ children: "+"
18353
+ })
18354
+ })
18355
+ ]
18356
+ })]
18357
+ }),
18358
+ /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("footer", {
18359
+ className: "da-attachment-footer",
18360
+ children: [(notice !== "" || !ready) && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18361
+ className: "da-attachment-status",
18362
+ children: [notice !== "" && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18363
+ role: "status",
18364
+ "aria-live": "polite",
18365
+ children: notice
18366
+ }), !ready && /* @__PURE__ */ (0, react_jsx_runtime.jsx)("span", {
18367
+ className: "da-attachment-warning",
18368
+ children: "原附件已移除、已发送,或输入框正在发送。仍可下载 PNG。"
18369
+ })]
18370
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18371
+ className: "da-attachment-actions",
18372
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
18373
+ type: "button",
18374
+ className: "da-attachment-action",
18375
+ "aria-disabled": busy || size === null || history.marks.length === 0,
18376
+ onClick: () => {
18377
+ finish(true);
18378
+ },
18379
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(DownloadIcon, {}), "下载 PNG"]
18380
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("button", {
18381
+ type: "button",
18382
+ className: "da-attachment-action da-attachment-action-primary",
18383
+ "aria-disabled": busy || size === null || history.marks.length === 0 || !ready,
18384
+ title: "生成标注图片,并把逐条评论写入输入框;原图不变,也不会自动发送",
18385
+ onClick: () => {
18386
+ finish(false);
18387
+ },
18388
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)(CopyIcon, {}), "加入输入框"]
18389
+ })]
18390
+ })]
18391
+ }),
18392
+ confirmClose && /* @__PURE__ */ (0, react_jsx_runtime.jsxs)("div", {
18393
+ className: "da-attachment-confirm",
18394
+ role: "alert",
18395
+ 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", {
18396
+ className: "da-attachment-confirm-actions",
18397
+ children: [/* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18398
+ type: "button",
18399
+ onClick: () => {
18400
+ setConfirmClose(false);
18401
+ },
18402
+ children: "继续编辑"
18403
+ }), /* @__PURE__ */ (0, react_jsx_runtime.jsx)("button", {
18404
+ type: "button",
18405
+ className: "da-attachment-danger",
18406
+ onClick: () => {
18407
+ store.clear();
18408
+ },
18409
+ children: "放弃批注"
18410
+ })]
18411
+ })]
18412
+ })
18413
+ ]
18414
+ })
18415
+ })]
18416
+ });
18417
+ }
18418
+ function CommentPill(props) {
18419
+ const { mark, size, scale } = props;
18420
+ const input = (0, react.useRef)(null);
18421
+ (0, react.useEffect)(() => {
18422
+ input.current?.focus();
18423
+ }, [mark.id]);
18424
+ const [x, y] = attachmentFocusPoint(mark);
18425
+ const right = x > size.width * .62;
18426
+ const below = y < 92;
18427
+ const translateX = right ? "calc(-100% - 14px)" : "14px";
18428
+ const translateY = below ? "14px" : "calc(-100% - 14px)";
18429
+ return /* @__PURE__ */ (0, react_jsx_runtime.jsx)("div", {
18430
+ className: "da-attachment-comment-pill",
18431
+ role: "group",
18432
+ "aria-label": "批注评论",
18433
+ style: {
18434
+ left: `${x}px`,
18435
+ top: `${y}px`,
18436
+ transform: `scale(${1 / scale}) translate(${translateX}, ${translateY})`,
18437
+ transformOrigin: `${right ? "right" : "left"} ${below ? "top" : "bottom"}`
18438
+ },
18439
+ children: /* @__PURE__ */ (0, react_jsx_runtime.jsx)("textarea", {
18440
+ ref: input,
18441
+ "data-mark": mark.id,
18442
+ "aria-label": `批注 ${mark.id} 评论`,
18443
+ placeholder: "说明这里要怎么改…",
18444
+ rows: 1,
18445
+ maxLength: MAX_DETAIL_CHARS,
18446
+ value: mark.detail ?? "",
18447
+ onChange: (event) => {
18448
+ props.onChange(event.target.value);
18449
+ },
18450
+ onKeyDown: (event) => {
18451
+ if (event.key === "Escape") {
18452
+ event.preventDefault();
18453
+ props.onClose();
18454
+ }
18455
+ }
18456
+ })
18457
+ });
18458
+ }
18459
+ //#endregion
18460
+ //#region src/client/attachment-annotator/register.ts
18461
+ const ATTACHMENT_BODY_ID = "dsh-figmark/attachment";
18462
+ /**
18463
+ * One invisible floating seat. It listens only to its Session's native draft
18464
+ * thumbnails, so the shell keeps ownership of uploads, removal and sending.
18465
+ */
18466
+ function registerAttachmentAnnotator(ctx, slots) {
18467
+ const store = createAttachmentStore();
18468
+ ctx.effect(() => () => {
18469
+ store.dispose();
18470
+ }, "dsh-figmark: attachment draft lifecycle");
18471
+ const injected = (_sessionId) => ({
18472
+ store,
18473
+ conversation: attachmentConversation(ctx.get("conversation"))
18474
+ });
18475
+ ctx.effect(() => slots.inject("conversation.input.overlay", () => slots.register({
18476
+ name: "conversation.input.overlay",
18477
+ id: ATTACHMENT_BODY_ID,
18478
+ order: 80,
18479
+ inject: injected
18480
+ }, AttachmentOverlay)), "dsh-figmark: attachment overlay");
18481
+ }
18482
+ //#endregion
17281
18483
  //#region src/client/index.tsx
17282
18484
  /** Plugin name reported to the client module loader. */
17283
18485
  const name = "dsh-figmark-client";
@@ -17346,6 +18548,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
17346
18548
  */
17347
18549
  function canOpenFigure(ctx, sessionId, path, html) {
17348
18550
  if (sessionId === "" || path === "") return false;
18551
+ if (/^[a-z][a-z\d+.-]*:\/\//iu.test(path) && !isLivePage(path, html)) return false;
17349
18552
  const sidebar = ctx.get("sidebarRight");
17350
18553
  if (sidebar === void 0) return false;
17351
18554
  return !isLivePage(path, html) || sidebar.openTab !== void 0;
@@ -17422,6 +18625,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
17422
18625
  ctx.effect(() => sidebarTabs.register({
17423
18626
  id: SERVICE_BODY_ID,
17424
18627
  kind: SERVICE_KIND,
18628
+ keepMounted: true,
17425
18629
  priority: "extension",
17426
18630
  title: () => locale.bind(NAMESPACE)("serviceTitle"),
17427
18631
  guide: [{
@@ -17530,6 +18734,7 @@ button.da-card:hover .da-card-go{background:var(--da-accent-soft,rgba(23,109,242
17530
18734
  localeId: locale.getLocale().locale ?? "zh"
17531
18735
  })
17532
18736
  }, PendingCard)), "dsh-figmark: parked annotation");
18737
+ registerAttachmentAnnotator(ctx, slots);
17533
18738
  }
17534
18739
  //#endregion
17535
18740
  exports.BODY_ID = BODY_ID;