dsh-figmark 1.2.1 → 1.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +52 -0
- package/PUBLISHING.md +2 -0
- package/README.md +67 -10
- package/lib/client.js +12469 -4425
- package/lib/client.pdf.js +157 -79
- package/lib/index.js +1398 -48
- package/market/index.json +12 -4
- package/package.json +7 -2
package/lib/index.js
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import { createRequire } from "node:module";
|
|
1
2
|
import { mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises";
|
|
2
3
|
import { basename, dirname, extname, isAbsolute, join, resolve } from "node:path";
|
|
4
|
+
import { createServer, request } from "node:http";
|
|
5
|
+
import { connect } from "node:net";
|
|
6
|
+
import { readFileSync } from "node:fs";
|
|
3
7
|
import { createHash } from "node:crypto";
|
|
4
8
|
//#region src/host/guidance.ts
|
|
5
9
|
/**
|
|
@@ -22,32 +26,51 @@ const GUIDANCE = [
|
|
|
22
26
|
"用户可能在文档预览里直接标注(箭头/框选/椭圆/手绘/单点),发送给你的消息以【附图标注】开头,正文就是**逐条标注清单**,不需要再去读任何附加文件(标注文件是投递用的临时件,发出即删)。收到时按下列纪律处理:",
|
|
23
27
|
"",
|
|
24
28
|
"1. 逐条读消息正文的清单:每条形如 `① 箭头 (10,10) → (40,30),批注:标号指错了`。括号里是图面像素坐标,原点在左上角;框选与圈选给的是**左上角-右下角**,手绘线给的是它画在哪个框里。`批注:` 之后是用户写的**详细说明**——那是这条标注唯一的内容,按它判断意图。有的条目在形状后面紧跟一个圆括号,写着该处落在哪个元素上(SVG 给元素标题/`id`/元素文字,HTML 给 `selector`/元素文字),定位时优先用它。",
|
|
25
|
-
"1a. 若某条写的是**元素**而不是形状(`① 元素 …`),那条标注指的不是\"在这里画个框\",而是**那一个元素本身**:圆括号里给的是选中时抽到的名字(元素标题 /
|
|
29
|
+
"1a. 若某条写的是**元素**而不是形状(`① 元素 …`),那条标注指的不是\"在这里画个框\",而是**那一个元素本身**:圆括号里给的是选中时抽到的名字(元素标题 / 元素文字 / 选择器 / `<g id=\"patch_2\">` 这样的开标签——最后一种里唯一能搜的就是那个 `id`),按它去源文件里找到那一个元素,改的就是它,不是它周围的一块区域。这类标注没有可挪动的形状,用户说的\"这里\"就是那个元素。",
|
|
26
30
|
"2. **PDF 是整份文档一份标注**:每条标注都带 `第 N 页`,坐标是那一页自己的(原点在该页左上角)。看每条标注前先确认它的页码。",
|
|
27
31
|
"3. 若标注超出原图边界,标注图会把越界的那圈纸也画出来,并用**灰色虚线**框住原图真正的边缘:虚线**里面**是原图,**外面**是你画的标注伸出去的部分——那块不属于图,不要把它当成新增的画布内容。有形状的标注(箭头/框选/椭圆/手绘)会附一张标注图(`<图名>.annotated.png`,PDF 则是一份**带批注的 PDF**,其批注是原生 PDF 注释对象:`/Ink` 手绘、`/Square` 框选、`/Circle` 圈选、`/Line` 箭头),可直接打开核对图面位置。**若消息说「本条标注只有位置点,没有标注图」,那就是全部内容:只有坐标加一句说明,直接按原图定位处理,不要去找不存在的附件。**",
|
|
28
32
|
"4. 逐条处理标注:每条标注都要落到具体的图面改动,并在答复里逐条回应(编号与标注一致)。无法实现的标注要说明原因,不要静默跳过。",
|
|
29
33
|
"5. 修改附图以**标注所指的生成源**为准(绘制脚本、SVG 源或 `generate_patent_figure` 的入参),不要直接涂改导出的位图;PDF 同理,改的是生成该 PDF 的源头,不是那份带批注的副本。未标注的部分保持不动。",
|
|
30
34
|
"6. 改完图后重新渲染并核对:图面尺寸/坐标若变化,旧标注的坐标会失准,此时以 `anchor` 与标注文字为准复核一遍,再回报结果。",
|
|
31
|
-
"7. 若消息以【HTML
|
|
32
|
-
"8. 处理 HTML
|
|
33
|
-
].join("\n");
|
|
34
|
-
/** Section order used for the Markdown edit-proposal guidance (after the annotation one). */
|
|
35
|
-
const EDIT_GUIDANCE_ORDER = 8991;
|
|
36
|
-
/** Stable section name, unique in the assembled prompt. */
|
|
37
|
-
const EDIT_GUIDANCE_NAME = "dsh-figmark:markdown-edits";
|
|
38
|
-
/** The guidance text appended for messages that carry a Markdown difference. */
|
|
39
|
-
const EDIT_GUIDANCE = [
|
|
40
|
-
"## Markdown 修改建议(dsh-figmark)",
|
|
41
|
-
"",
|
|
42
|
-
"用户可能在文档预览里直接编辑 Markdown,保存后你会收到一条【文档修改建议】开头、正文是一段 unified diff 的用户消息(`--- a/<文件名>`、`+++ b/<文件名>`,`@@` 头给出两侧的起始行与行数)。收到时按下列纪律处理:",
|
|
43
|
-
"",
|
|
44
|
-
"1. 先读文件(消息里给出路径)。**用户没有把改动写进磁盘**:磁盘上的文件仍是旧版,`a` 侧是用户打开时的那一版,`b` 侧是用户改完的结果。",
|
|
45
|
-
"2. 把 diff 应用到源文件:`-` 行删除、`+` 行写入、空格开头的是上下文(保持原样),逐处落地;不要重写整个文件,diff 之外的部分保持不动。",
|
|
46
|
-
"3. 消息里若说「另有 N 处改动未在本消息里展开」,完整结果就是随消息发送的 `<文件名>.edited.md` 附件;以它为准。",
|
|
47
|
-
"4. 消息里若带「我的说明:」清单,那是用户对某一行/某一处改动写的理由(形如 `1. 第 6 行(@@ -3,7 +3,7 @@):…`);落地时按它判断意图,说明里提到的行号是**基线(a 侧)**的行号。",
|
|
48
|
-
"5. 消息里若说文件在编辑期间被改动过、或 diff 以整块替换呈现,先核对再落地;无法安全应用时说明原因,不要猜着改。",
|
|
49
|
-
"6. 落地后逐处回应(顺序与 diff 一致),并说明哪些改动你没有采纳以及为什么。"
|
|
35
|
+
"7. 若消息以【HTML 标注】开头,那是**本机 loopback 服务页**:消息会给出页面地址。标注画在当时的真实页面视口里,`points` 只是当时的视口坐标,页面重排、滚动或更换视口后可能失准;回源定位以 `marks[].anchor.selector` 为主——每段是 `tag:nth-of-type(n)`,遇到 `#id` 就在那里截断,因此它要么以 `body > …` 开头,要么直接是 `#id > …`;`anchor.text` 是该元素当时的文字。SVG 的元素标注同理,只是那条路径以 `<svg>` 元素本身为根(`svg > g:nth-of-type(2) > …`),遇到 `#id` 同样在那里截断——所以它要么以 `svg > …` 开头,要么直接是 `#id > …`。",
|
|
36
|
+
"8. 处理 HTML 标注时,改的是**服务这个页面的源**(模板、组件、样式、生成脚本或静态资源),不是浏览器里的渲染结果。live preview 保留了真实脚本、路由和资源加载,所以页面里的状态与视觉是该服务实际给出的;若页面依赖登录态、外部 API 或一个未启动的服务,先按消息里的地址核对运行条件再改源。"
|
|
50
37
|
].join("\n");
|
|
38
|
+
/** Route prefix owned by this plugin. */
|
|
39
|
+
const ROUTE_PREFIX = "/dsh-figmark";
|
|
40
|
+
//#endregion
|
|
41
|
+
//#region src/shared/live.ts
|
|
42
|
+
/**
|
|
43
|
+
* The wire between a rendered page and the panel.
|
|
44
|
+
*
|
|
45
|
+
* A page in the frame is not a document the panel holds: even when the file is on
|
|
46
|
+
* the reader's disk, the frame's own scripts have to run — a deck that animates, a
|
|
47
|
+
* video that plays, a link that means something — and a frame allowed to run scripts
|
|
48
|
+
* must not share the panel's origin. So the frame is opaque to the parent, and the
|
|
49
|
+
* only way across is a small script injected into it and `postMessage` back.
|
|
50
|
+
*
|
|
51
|
+
* This module is that vocabulary, and it is also the fence around it: everything it
|
|
52
|
+
* accepts is parsed field by field, because the other side is a page and a page can
|
|
53
|
+
* lie. A lie must stay a name — never a coordinate the reader never pointed at.
|
|
54
|
+
* @module dsh-figmark/shared/live
|
|
55
|
+
*/
|
|
56
|
+
/** Route the panel opens a live page through, under the plugin prefix. */
|
|
57
|
+
const LIVE_ROUTE = "/live";
|
|
58
|
+
/**
|
|
59
|
+
* Largest PNG data URL a live page may send back.
|
|
60
|
+
*
|
|
61
|
+
* The message is held by both browsing contexts while it crosses the frame
|
|
62
|
+
* boundary. Failing below the parser's hard ceiling leaves room for the rest of
|
|
63
|
+
* the object instead of sending an answer the receiving side would discard.
|
|
64
|
+
*/
|
|
65
|
+
const MAX_LIVE_CAPTURE_DATA_URL = 155e5;
|
|
66
|
+
//#endregion
|
|
67
|
+
//#region src/shared/services.ts
|
|
68
|
+
/**
|
|
69
|
+
* The small vocabulary shared by the service browser and the Host scan.
|
|
70
|
+
* @module dsh-figmark/shared/services
|
|
71
|
+
*/
|
|
72
|
+
/** Route that probes loopback services for the browser. */
|
|
73
|
+
const SERVICE_DISCOVER_ROUTE = "/services";
|
|
51
74
|
//#endregion
|
|
52
75
|
//#region src/shared/address.ts
|
|
53
76
|
/** Documented prefix of every file address. */
|
|
@@ -251,8 +274,1052 @@ async function resolveTarget(address, sessionCwd) {
|
|
|
251
274
|
message: `no such figure: ${absolute}`
|
|
252
275
|
};
|
|
253
276
|
}
|
|
254
|
-
/**
|
|
255
|
-
const
|
|
277
|
+
/** The tag injected into every interactive HTML page. */
|
|
278
|
+
const PICKER_TAG = `<script src="/__dsh_figmark_picker.js"><\/script>`;
|
|
279
|
+
/** Largest HTML document either server will buffer in order to inject the picker. */
|
|
280
|
+
const MAX_HTML_BYTES = 8 * 1024 * 1024;
|
|
281
|
+
/**
|
|
282
|
+
* A page-level policy that would reject the picker script.
|
|
283
|
+
*
|
|
284
|
+
* The proxy already removes the same policy when it arrives as a response header.
|
|
285
|
+
* HTML can carry the policy in a meta tag as well, and a document-level
|
|
286
|
+
* `script-src 'none'` would otherwise leave a working page with a dead picker.
|
|
287
|
+
*/
|
|
288
|
+
const CSP_META = /<meta\b(?=[^>]*\bhttp-equiv\s*=\s*(?:"\s*content-security-policy(?:-report-only)?\s*"|'\s*content-security-policy(?:-report-only)?\s*'|content-security-policy(?:-report-only)?))[^>]*>/giu;
|
|
289
|
+
/**
|
|
290
|
+
* Remove document-level CSP declarations before the picker is injected.
|
|
291
|
+
* @param html - the page as the source sent it.
|
|
292
|
+
* @returns the page without CSP meta tags.
|
|
293
|
+
*/
|
|
294
|
+
function stripCspMeta(html) {
|
|
295
|
+
return html.replace(CSP_META, "");
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* Put the picker tag in front of a page's last `</body>`.
|
|
299
|
+
* @param html - the page as the source sent it.
|
|
300
|
+
* @param tag - the tag to inject.
|
|
301
|
+
* @returns the page the frame receives.
|
|
302
|
+
*/
|
|
303
|
+
function injectPicker(html, tag = PICKER_TAG) {
|
|
304
|
+
const close = html.toLowerCase().lastIndexOf("</body>");
|
|
305
|
+
return close < 0 ? `${html}${tag}` : `${html.slice(0, close)}${tag}${html.slice(close)}`;
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* The picker, as the page receives it.
|
|
309
|
+
*
|
|
310
|
+
* On load it marks the document element with `data-dsh-figmark="live"`, so the page
|
|
311
|
+
* (and anyone looking at it) can tell it is being previewed, and it reports that it
|
|
312
|
+
* is ready along with the current scroll — the panel needs both before it can place
|
|
313
|
+
* a single mark. While annotation is armed the pointer is reported once per animation
|
|
314
|
+
* frame and a click names the element instead of following it; while it is not, this
|
|
315
|
+
* script listens and does nothing else.
|
|
316
|
+
*
|
|
317
|
+
* Reports go to `window.parent` with target origin `'*'`: the panel is on another
|
|
318
|
+
* origin by construction, and the message carries nothing a hostile page could not
|
|
319
|
+
* already see. The panel validates the shape on arrival — this side only promises to
|
|
320
|
+
* send it.
|
|
321
|
+
*
|
|
322
|
+
* `selector` is positional and nothing else: `tag:nth-of-type(n)` steps joined by
|
|
323
|
+
* `' > '`, rooted at `body` but without naming it, so `body` itself is `'body'`.
|
|
324
|
+
* A positional path survives a page whose ids are regenerated on every render, which
|
|
325
|
+
* is exactly what a live page's ids do — and it is the same grammar the file
|
|
326
|
+
* preview's own path builder writes, so a saved mark resolves against either.
|
|
327
|
+
*/
|
|
328
|
+
const PICKER_SCRIPT = `/*! modern-screenshot v4.7.0 | MIT */
|
|
329
|
+
${readFileSync(createRequire(import.meta.url).resolve("modern-screenshot/dist/index.js"), "utf8")}
|
|
330
|
+
;(function () {
|
|
331
|
+
if (window.__dshFigmarkPicker) return
|
|
332
|
+
window.__dshFigmarkPicker = true
|
|
333
|
+
|
|
334
|
+
var MARKER = 'dsh-figmark:live'
|
|
335
|
+
var doc = document
|
|
336
|
+
var armed = false
|
|
337
|
+
var pending = 0
|
|
338
|
+
var latest = null
|
|
339
|
+
var latestKey = ''
|
|
340
|
+
var depth = 0
|
|
341
|
+
var lastScroll = ''
|
|
342
|
+
var watched = []
|
|
343
|
+
var lastBounds = ''
|
|
344
|
+
var boundsScheduled = 0
|
|
345
|
+
var lastHover = ''
|
|
346
|
+
var lastSurface
|
|
347
|
+
var surfaceScheduled = 0
|
|
348
|
+
var revealedStyles = []
|
|
349
|
+
var revealSeq = 0
|
|
350
|
+
|
|
351
|
+
// The common spellings presentation frameworks use for the one page in front.
|
|
352
|
+
// This is deliberately a small convention rather than "the largest element":
|
|
353
|
+
// marking an arbitrary large container as a page would hide valid annotations.
|
|
354
|
+
var SURFACE_QUERY = [
|
|
355
|
+
'[data-slide-current="true"]',
|
|
356
|
+
'[data-slide-active="true"]',
|
|
357
|
+
'.slide',
|
|
358
|
+
'.dk-slide',
|
|
359
|
+
'.page.current',
|
|
360
|
+
'.page.active',
|
|
361
|
+
'.page.is-live',
|
|
362
|
+
'.page.live',
|
|
363
|
+
'.swiper-slide',
|
|
364
|
+
'.carousel-item',
|
|
365
|
+
'.reveal .slides section',
|
|
366
|
+
'.remark-slide',
|
|
367
|
+
'.remark-visible',
|
|
368
|
+
'.step.active',
|
|
369
|
+
'[role="tabpanel"]',
|
|
370
|
+
'[data-slide]'
|
|
371
|
+
].join(',')
|
|
372
|
+
|
|
373
|
+
doc.documentElement.setAttribute('data-dsh-figmark', 'live')
|
|
374
|
+
var cursorStyle = doc.createElement('style')
|
|
375
|
+
cursorStyle.setAttribute('data-dsh-figmark-style', '')
|
|
376
|
+
cursorStyle.textContent = 'html[data-dsh-figmark-armed="true"] *{cursor:crosshair!important}'
|
|
377
|
+
;(doc.head || doc.documentElement).appendChild(cursorStyle)
|
|
378
|
+
|
|
379
|
+
function pathOf(el) {
|
|
380
|
+
if (el === doc.body) return 'body'
|
|
381
|
+
var steps = []
|
|
382
|
+
for (var node = el; node && node !== doc.body; node = node.parentElement) {
|
|
383
|
+
var index = 1
|
|
384
|
+
for (var sib = node.previousElementSibling; sib; sib = sib.previousElementSibling) {
|
|
385
|
+
if (sib.tagName === node.tagName) index += 1
|
|
386
|
+
}
|
|
387
|
+
steps.unshift(node.tagName.toLowerCase() + ':nth-of-type(' + index + ')')
|
|
388
|
+
}
|
|
389
|
+
return steps.join(' > ')
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
function ladderOf(el) {
|
|
393
|
+
var answer = []
|
|
394
|
+
for (var node = el; node && node !== doc.documentElement; node = node.parentElement) {
|
|
395
|
+
answer.push(node)
|
|
396
|
+
}
|
|
397
|
+
return answer
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
function keyOf(ladder) {
|
|
401
|
+
var parts = []
|
|
402
|
+
for (var i = 0; i < ladder.length; i += 1) parts.push(pathOf(ladder[i]))
|
|
403
|
+
return parts.join('|')
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
function scrollOf() {
|
|
407
|
+
return [window.scrollX || 0, window.scrollY || 0]
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
function send(message) {
|
|
411
|
+
var parent = window.parent
|
|
412
|
+
if (!parent) return
|
|
413
|
+
try { parent.postMessage(message, '*') } catch (error) { return }
|
|
414
|
+
}
|
|
415
|
+
|
|
416
|
+
function report(kind, el, ladder, index) {
|
|
417
|
+
var rect = el.getBoundingClientRect()
|
|
418
|
+
var scroll = scrollOf()
|
|
419
|
+
var selector = pathOf(el)
|
|
420
|
+
var hoverKey = selector + '|' + scroll[0] + ',' + scroll[1] + '|'
|
|
421
|
+
+ rect.left + ',' + rect.top + ',' + rect.width + ',' + rect.height
|
|
422
|
+
if (kind === 'hover') {
|
|
423
|
+
if (hoverKey === lastHover) return
|
|
424
|
+
lastHover = hoverKey
|
|
425
|
+
}
|
|
426
|
+
var message = {
|
|
427
|
+
figmark: MARKER,
|
|
428
|
+
kind: kind,
|
|
429
|
+
tag: el.tagName.toLowerCase(),
|
|
430
|
+
selector: selector,
|
|
431
|
+
bbox: [rect.left + scroll[0], rect.top + scroll[1], rect.width, rect.height],
|
|
432
|
+
scroll: scroll,
|
|
433
|
+
level: index + 1,
|
|
434
|
+
count: ladder.length
|
|
435
|
+
}
|
|
436
|
+
var text = (el.textContent || '').replace(/\\s+/g, ' ').trim().slice(0, 80)
|
|
437
|
+
if (text) message.text = text
|
|
438
|
+
if (el.id) message.id = el.id
|
|
439
|
+
var cls = el.getAttribute('class')
|
|
440
|
+
if (cls && cls.trim()) message['class'] = cls.trim()
|
|
441
|
+
if (kind === 'pick') {
|
|
442
|
+
var owner = surfaceOf(el)
|
|
443
|
+
message.surface = owner ? pathOf(owner) : null
|
|
444
|
+
}
|
|
445
|
+
send(message)
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
function choose(ladder) {
|
|
449
|
+
var key = keyOf(ladder)
|
|
450
|
+
if (key !== latestKey) {
|
|
451
|
+
latestKey = key
|
|
452
|
+
depth = 0
|
|
453
|
+
}
|
|
454
|
+
depth = Math.max(0, Math.min(depth, ladder.length - 1))
|
|
455
|
+
return depth
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function under(event) {
|
|
459
|
+
var el = event.target
|
|
460
|
+
if (!el || el.nodeType !== 1) return null
|
|
461
|
+
return el === doc.documentElement ? null : el
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
function rendered(el) {
|
|
465
|
+
if (!el) return false
|
|
466
|
+
var style = window.getComputedStyle(el)
|
|
467
|
+
if (style.display === 'none'
|
|
468
|
+
|| style.visibility === 'hidden'
|
|
469
|
+
|| style.visibility === 'collapse'
|
|
470
|
+
|| style.contentVisibility === 'hidden'
|
|
471
|
+
|| parseFloat(style.opacity) <= 0) return false
|
|
472
|
+
// A slide is often put away by an ancestor's opacity. The element's own computed
|
|
473
|
+
// visibility stays "visible" there, so check the compositing chain as well.
|
|
474
|
+
for (var node = el.parentElement; node; node = node.parentElement) {
|
|
475
|
+
var parentStyle = window.getComputedStyle(node)
|
|
476
|
+
if (parentStyle.display === 'none'
|
|
477
|
+
|| parentStyle.contentVisibility === 'hidden'
|
|
478
|
+
|| parseFloat(parentStyle.opacity) <= 0) return false
|
|
479
|
+
}
|
|
480
|
+
if (typeof el.checkVisibility === 'function') {
|
|
481
|
+
try {
|
|
482
|
+
return el.checkVisibility({
|
|
483
|
+
checkOpacity: true,
|
|
484
|
+
checkVisibilityCSS: true,
|
|
485
|
+
opacityProperty: true,
|
|
486
|
+
visibilityProperty: true,
|
|
487
|
+
contentVisibilityAuto: true
|
|
488
|
+
})
|
|
489
|
+
} catch (error) {
|
|
490
|
+
return true
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
return true
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
function shown(el) {
|
|
497
|
+
if (!rendered(el)) return false
|
|
498
|
+
var rect = el.getBoundingClientRect()
|
|
499
|
+
if (rect.width <= 0 || rect.height <= 0) return false
|
|
500
|
+
var viewportWidth = window.innerWidth || doc.documentElement.clientWidth || 0
|
|
501
|
+
var viewportHeight = window.innerHeight || doc.documentElement.clientHeight || 0
|
|
502
|
+
if (rect.right <= 0 || rect.bottom <= 0 || rect.left >= viewportWidth || rect.top >= viewportHeight) {
|
|
503
|
+
return false
|
|
504
|
+
}
|
|
505
|
+
return true
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function inViewport(el) {
|
|
509
|
+
var rect = el.getBoundingClientRect()
|
|
510
|
+
var viewportWidth = window.innerWidth || doc.documentElement.clientWidth || 0
|
|
511
|
+
var viewportHeight = window.innerHeight || doc.documentElement.clientHeight || 0
|
|
512
|
+
return rect.left >= -1 && rect.top >= -1
|
|
513
|
+
&& rect.right <= viewportWidth + 1 && rect.bottom <= viewportHeight + 1
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function boxInViewport(box) {
|
|
517
|
+
var scroll = scrollOf()
|
|
518
|
+
var viewportWidth = window.innerWidth || doc.documentElement.clientWidth || 0
|
|
519
|
+
var viewportHeight = window.innerHeight || doc.documentElement.clientHeight || 0
|
|
520
|
+
var x = typeof box[0] === 'number' ? box[0] : 0
|
|
521
|
+
var y = typeof box[1] === 'number' ? box[1] : 0
|
|
522
|
+
var width = typeof box[2] === 'number' ? box[2] : 0
|
|
523
|
+
var height = typeof box[3] === 'number' ? box[3] : 0
|
|
524
|
+
return x >= scroll[0] - 1 && y >= scroll[1] - 1
|
|
525
|
+
&& x + width <= scroll[0] + viewportWidth + 1
|
|
526
|
+
&& y + height <= scroll[1] + viewportHeight + 1
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
function measure(selector) {
|
|
530
|
+
var el = null
|
|
531
|
+
try { el = doc.querySelector(selector) } catch (error) { el = null }
|
|
532
|
+
if (!el) return { selector: selector, bbox: [0, 0, 0, 0], visible: false }
|
|
533
|
+
var rect = el.getBoundingClientRect()
|
|
534
|
+
var scroll = scrollOf()
|
|
535
|
+
return {
|
|
536
|
+
selector: selector,
|
|
537
|
+
bbox: [rect.left + scroll[0], rect.top + scroll[1], rect.width, rect.height],
|
|
538
|
+
visible: shown(el)
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function delay(ms) {
|
|
543
|
+
return new Promise(function (resolve) { window.setTimeout(resolve, ms) })
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function waitForRendered(el, remaining) {
|
|
547
|
+
if (rendered(el)) return Promise.resolve(true)
|
|
548
|
+
if (remaining <= 0) return Promise.resolve(false)
|
|
549
|
+
return delay(45).then(function () { return waitForRendered(el, remaining - 1) })
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
function restoreHidden() {
|
|
553
|
+
for (var i = revealedStyles.length - 1; i >= 0; i -= 1) {
|
|
554
|
+
var item = revealedStyles[i]
|
|
555
|
+
if (item.value) item.el.style.setProperty(item.name, item.value, item.priority)
|
|
556
|
+
else item.el.style.removeProperty(item.name)
|
|
557
|
+
}
|
|
558
|
+
revealedStyles = []
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
function overrideHidden(node, name, value) {
|
|
562
|
+
revealedStyles.push({
|
|
563
|
+
el: node,
|
|
564
|
+
name: name,
|
|
565
|
+
value: node.style.getPropertyValue(name),
|
|
566
|
+
priority: node.style.getPropertyPriority(name)
|
|
567
|
+
})
|
|
568
|
+
node.style.setProperty(name, value, 'important')
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function revealHidden(el, stop) {
|
|
572
|
+
var outermost = null
|
|
573
|
+
for (var node = el; node && node !== doc.body && node !== doc.documentElement && node !== stop; node = node.parentElement) {
|
|
574
|
+
var style = window.getComputedStyle(node)
|
|
575
|
+
if (style.display === 'none') overrideHidden(node, 'display', 'revert')
|
|
576
|
+
if (style.visibility === 'hidden' || style.visibility === 'collapse') {
|
|
577
|
+
overrideHidden(node, 'visibility', 'visible')
|
|
578
|
+
}
|
|
579
|
+
if (style.contentVisibility === 'hidden') overrideHidden(node, 'content-visibility', 'visible')
|
|
580
|
+
if (parseFloat(style.opacity) <= 0) overrideHidden(node, 'opacity', '1')
|
|
581
|
+
outermost = node
|
|
582
|
+
}
|
|
583
|
+
if (outermost && window.getComputedStyle(outermost).pointerEvents !== 'none') {
|
|
584
|
+
overrideHidden(outermost, 'pointer-events', 'none')
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function surfaceOf(el) {
|
|
589
|
+
for (var node = el; node && node !== doc.documentElement; node = node.parentElement) {
|
|
590
|
+
try {
|
|
591
|
+
if (node.matches(SURFACE_QUERY)) return node
|
|
592
|
+
} catch (error) {
|
|
593
|
+
return null
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
return null
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
function nearestSurface() {
|
|
600
|
+
var surfaces = []
|
|
601
|
+
try { surfaces = Array.prototype.slice.call(doc.querySelectorAll(SURFACE_QUERY)) } catch (error) { return null }
|
|
602
|
+
for (var i = 0; i < surfaces.length; i += 1) {
|
|
603
|
+
var rect = surfaces[i].getBoundingClientRect()
|
|
604
|
+
// A deck often clones every slide into its overview. Those copies keep the
|
|
605
|
+
// same classes but have no layout, so they cannot be navigation targets.
|
|
606
|
+
if (rect.width > 0 && rect.height > 0) return surfaces[i]
|
|
607
|
+
}
|
|
608
|
+
return null
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
function turnToward(target) {
|
|
612
|
+
var surfaces = []
|
|
613
|
+
try { surfaces = Array.prototype.slice.call(doc.querySelectorAll(SURFACE_QUERY)) } catch (error) { return Promise.resolve(false) }
|
|
614
|
+
var wanted = surfaces.indexOf(target)
|
|
615
|
+
var current = visibleSurface()
|
|
616
|
+
var from = current ? surfaces.indexOf(current) : -1
|
|
617
|
+
if (wanted < 0 || from < 0 || wanted === from) return Promise.resolve(shown(target))
|
|
618
|
+
var key = wanted > from ? 'ArrowRight' : 'ArrowLeft'
|
|
619
|
+
var steps = Math.min(32, Math.abs(wanted - from) + 2)
|
|
620
|
+
function next(remaining) {
|
|
621
|
+
if (remaining <= 0 || shown(target)) return Promise.resolve(shown(target))
|
|
622
|
+
var node = doc.activeElement || doc.body || doc.documentElement
|
|
623
|
+
var event = new KeyboardEvent('keydown', {
|
|
624
|
+
key: key,
|
|
625
|
+
code: key,
|
|
626
|
+
keyCode: key === 'ArrowRight' ? 39 : 37,
|
|
627
|
+
which: key === 'ArrowRight' ? 39 : 37,
|
|
628
|
+
bubbles: true,
|
|
629
|
+
cancelable: true
|
|
630
|
+
})
|
|
631
|
+
node.dispatchEvent(event)
|
|
632
|
+
return delay(45).then(function () { return next(remaining - 1) })
|
|
633
|
+
}
|
|
634
|
+
return next(steps)
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
function reveal(requestId, selector, surface, bbox) {
|
|
638
|
+
revealSeq += 1
|
|
639
|
+
var seq = revealSeq
|
|
640
|
+
restoreHidden()
|
|
641
|
+
function current() {
|
|
642
|
+
return seq === revealSeq
|
|
643
|
+
}
|
|
644
|
+
var requestedEl = null
|
|
645
|
+
if (selector) {
|
|
646
|
+
try { requestedEl = doc.querySelector(selector) } catch (error) { requestedEl = null }
|
|
647
|
+
}
|
|
648
|
+
var ownerSurface = null
|
|
649
|
+
var surfaceEl = null
|
|
650
|
+
if (requestedEl) {
|
|
651
|
+
// The element is the strongest evidence of where it lives. A mark made
|
|
652
|
+
// before page ownership was recorded may carry the slide that was open at
|
|
653
|
+
// creation time instead, which would leave us waiting on the wrong page.
|
|
654
|
+
ownerSurface = surfaceOf(requestedEl)
|
|
655
|
+
surfaceEl = ownerSurface
|
|
656
|
+
}
|
|
657
|
+
if (!surfaceEl && requestedEl && !shown(requestedEl)) {
|
|
658
|
+
surfaceEl = nearestSurface()
|
|
659
|
+
}
|
|
660
|
+
if (!surfaceEl && surface) {
|
|
661
|
+
try { surfaceEl = doc.querySelector(surface) } catch (error) { surfaceEl = null }
|
|
662
|
+
if (!surfaceEl) {
|
|
663
|
+
send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'wrong-surface' })
|
|
664
|
+
return
|
|
665
|
+
}
|
|
666
|
+
}
|
|
667
|
+
var ready = shown(surfaceEl) ? Promise.resolve(true) : surfaceEl ? turnToward(surfaceEl) : Promise.resolve(true)
|
|
668
|
+
ready.then(function (surfaceReady) {
|
|
669
|
+
if (!current()) return
|
|
670
|
+
if (!surfaceReady) {
|
|
671
|
+
send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'wrong-surface' })
|
|
672
|
+
return
|
|
673
|
+
}
|
|
674
|
+
if (selector) {
|
|
675
|
+
var el = requestedEl
|
|
676
|
+
if (!el) {
|
|
677
|
+
send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'missing' })
|
|
678
|
+
return
|
|
679
|
+
}
|
|
680
|
+
waitForRendered(el, 4).then(function (elementReady) {
|
|
681
|
+
if (!current()) return false
|
|
682
|
+
if (!elementReady) {
|
|
683
|
+
// A cover, modal, or intro layer can sit outside every slide and stay
|
|
684
|
+
// hidden after the deck has advanced. Restore only the hidden ancestors
|
|
685
|
+
// below the owning surface; the source and the page's state are untouched.
|
|
686
|
+
revealHidden(el, ownerSurface)
|
|
687
|
+
return waitForRendered(el, 10)
|
|
688
|
+
}
|
|
689
|
+
return elementReady
|
|
690
|
+
}).then(function (elementReady) {
|
|
691
|
+
if (!current()) return
|
|
692
|
+
if (!elementReady) {
|
|
693
|
+
restoreHidden()
|
|
694
|
+
send({ figmark: MARKER, kind: 'reveal-result', requestId: requestId, status: 'hidden' })
|
|
695
|
+
return
|
|
696
|
+
}
|
|
697
|
+
// A fully visible element must not move. Centring used to shift decks and
|
|
698
|
+
// fixed-stage pages under the reader even when the mark was already on
|
|
699
|
+
// screen; nearest changes only what is actually clipped.
|
|
700
|
+
if (window.getComputedStyle(el).position !== 'fixed' && !inViewport(el)) {
|
|
701
|
+
try { el.scrollIntoView({ block: 'nearest', inline: 'nearest', behavior: 'auto' }) } catch (error) { el.scrollIntoView() }
|
|
702
|
+
}
|
|
703
|
+
delay(0).then(function () {
|
|
704
|
+
if (!current()) return
|
|
705
|
+
sendScroll()
|
|
706
|
+
var visible = shown(el)
|
|
707
|
+
var answer = {
|
|
708
|
+
figmark: MARKER,
|
|
709
|
+
kind: 'reveal-result',
|
|
710
|
+
requestId: requestId,
|
|
711
|
+
status: visible ? 'revealed' : 'hidden',
|
|
712
|
+
surface: surfaceEl ? pathOf(surfaceEl) : null
|
|
713
|
+
}
|
|
714
|
+
if (visible && revealedStyles.length > 0) answer.temporary = true
|
|
715
|
+
send(answer)
|
|
716
|
+
})
|
|
717
|
+
})
|
|
718
|
+
return
|
|
719
|
+
}
|
|
720
|
+
var x = bbox && typeof bbox[0] === 'number' ? bbox[0] : 0
|
|
721
|
+
var y = bbox && typeof bbox[1] === 'number' ? bbox[1] : 0
|
|
722
|
+
if (!boxInViewport(bbox || [0, 0, 0, 0])) {
|
|
723
|
+
window.scrollTo({
|
|
724
|
+
left: Math.max(0, x - (window.innerWidth || 0) / 2),
|
|
725
|
+
top: Math.max(0, y - (window.innerHeight || 0) / 2),
|
|
726
|
+
behavior: 'auto'
|
|
727
|
+
})
|
|
728
|
+
}
|
|
729
|
+
delay(0).then(function () {
|
|
730
|
+
if (!current()) return
|
|
731
|
+
sendScroll()
|
|
732
|
+
var width = bbox && typeof bbox[2] === 'number' ? bbox[2] : 0
|
|
733
|
+
var height = bbox && typeof bbox[3] === 'number' ? bbox[3] : 0
|
|
734
|
+
var visible = width <= 0 || height <= 0
|
|
735
|
+
|| (x + width > window.scrollX && x < window.scrollX + (window.innerWidth || 0)
|
|
736
|
+
&& y + height > window.scrollY && y < window.scrollY + (window.innerHeight || 0))
|
|
737
|
+
send({
|
|
738
|
+
figmark: MARKER,
|
|
739
|
+
kind: 'reveal-result',
|
|
740
|
+
requestId: requestId,
|
|
741
|
+
status: visible ? 'revealed' : 'hidden'
|
|
742
|
+
})
|
|
743
|
+
})
|
|
744
|
+
})
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
function sendBounds(force) {
|
|
748
|
+
boundsScheduled = 0
|
|
749
|
+
if (watched.length === 0) return
|
|
750
|
+
var bounds = []
|
|
751
|
+
for (var i = 0; i < watched.length; i += 1) bounds.push(measure(watched[i]))
|
|
752
|
+
var key = JSON.stringify(bounds)
|
|
753
|
+
if (!force && key === lastBounds) return
|
|
754
|
+
lastBounds = key
|
|
755
|
+
send({ figmark: MARKER, kind: 'bounds', bounds: bounds })
|
|
756
|
+
}
|
|
757
|
+
|
|
758
|
+
function scheduleBounds() {
|
|
759
|
+
if (watched.length === 0 || boundsScheduled) return
|
|
760
|
+
// Do not store the request handle here: a test clock may run the callback before
|
|
761
|
+
// requestAnimationFrame returns, which would assign a truthy handle after the
|
|
762
|
+
// callback had already cleared the flag.
|
|
763
|
+
boundsScheduled = 1
|
|
764
|
+
window.requestAnimationFrame(function () { sendBounds(false) })
|
|
765
|
+
}
|
|
766
|
+
|
|
767
|
+
function sendScroll() {
|
|
768
|
+
var scroll = scrollOf()
|
|
769
|
+
var key = scroll[0] + ',' + scroll[1]
|
|
770
|
+
if (key !== lastScroll) {
|
|
771
|
+
lastScroll = key
|
|
772
|
+
send({ figmark: MARKER, kind: 'scroll', scroll: scroll })
|
|
773
|
+
}
|
|
774
|
+
scheduleBounds()
|
|
775
|
+
scheduleSurface()
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
function visibleSurface() {
|
|
779
|
+
var candidates
|
|
780
|
+
try { candidates = doc.querySelectorAll(SURFACE_QUERY) } catch (error) { return null }
|
|
781
|
+
for (var i = 0; i < candidates.length; i += 1) {
|
|
782
|
+
var el = candidates[i]
|
|
783
|
+
if (shown(el)) return el
|
|
784
|
+
}
|
|
785
|
+
return null
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function sendSurface(force) {
|
|
789
|
+
surfaceScheduled = 0
|
|
790
|
+
var el = visibleSurface()
|
|
791
|
+
var selector = el ? pathOf(el) : null
|
|
792
|
+
var key = selector === null ? '\\u0000none' : selector
|
|
793
|
+
if (selector === null && lastSurface === undefined) return
|
|
794
|
+
if (!force && key === lastSurface) return
|
|
795
|
+
lastSurface = key
|
|
796
|
+
send({ figmark: MARKER, kind: 'surface', selector: selector })
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
function scheduleSurface() {
|
|
800
|
+
if (surfaceScheduled) return
|
|
801
|
+
surfaceScheduled = 1
|
|
802
|
+
window.requestAnimationFrame(function () { sendSurface(false) })
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
doc.addEventListener('pointermove', function (event) {
|
|
806
|
+
if (!armed) return
|
|
807
|
+
var el = under(event)
|
|
808
|
+
if (!el) return
|
|
809
|
+
var ladder = ladderOf(el)
|
|
810
|
+
if (ladder.length === 0) return
|
|
811
|
+
var index = choose(ladder)
|
|
812
|
+
latest = { ladder: ladder, index: index }
|
|
813
|
+
if (pending) return
|
|
814
|
+
pending = 1
|
|
815
|
+
window.requestAnimationFrame(function () {
|
|
816
|
+
pending = 0
|
|
817
|
+
if (!latest) return
|
|
818
|
+
report('hover', latest.ladder[latest.index], latest.ladder, latest.index)
|
|
819
|
+
})
|
|
820
|
+
}, true)
|
|
821
|
+
|
|
822
|
+
// The pointer going off the page takes the name with it: a label left behind over
|
|
823
|
+
// an element the reader's pointer has already left is a label about nothing.
|
|
824
|
+
doc.documentElement.addEventListener('pointerleave', function () {
|
|
825
|
+
if (!armed) return
|
|
826
|
+
latest = null
|
|
827
|
+
lastHover = ''
|
|
828
|
+
send({ figmark: MARKER, kind: 'leave' })
|
|
829
|
+
})
|
|
830
|
+
|
|
831
|
+
doc.addEventListener('click', function (event) {
|
|
832
|
+
if (!armed) return
|
|
833
|
+
var el = under(event)
|
|
834
|
+
if (!el) return
|
|
835
|
+
var ladder = ladderOf(el)
|
|
836
|
+
if (ladder.length === 0) return
|
|
837
|
+
var index = choose(ladder)
|
|
838
|
+
event.preventDefault()
|
|
839
|
+
event.stopImmediatePropagation()
|
|
840
|
+
report('pick', ladder[index], ladder, index)
|
|
841
|
+
}, true)
|
|
842
|
+
|
|
843
|
+
// Arrow keys climb the same ladder the hover box is promising. Escape dismisses
|
|
844
|
+
// the panel's current comment layer; both belong to the frame because that is
|
|
845
|
+
// where the keyboard focus is once the reader points into the page.
|
|
846
|
+
doc.addEventListener('keydown', function (event) {
|
|
847
|
+
if (!armed) return
|
|
848
|
+
if (event.key === 'Escape') {
|
|
849
|
+
event.preventDefault()
|
|
850
|
+
event.stopImmediatePropagation()
|
|
851
|
+
latest = null
|
|
852
|
+
lastHover = ''
|
|
853
|
+
send({ figmark: MARKER, kind: 'cancel' })
|
|
854
|
+
return
|
|
855
|
+
}
|
|
856
|
+
if (!latest) return
|
|
857
|
+
if (event.key === 'ArrowUp' || event.key === 'ArrowDown') {
|
|
858
|
+
event.preventDefault()
|
|
859
|
+
event.stopImmediatePropagation()
|
|
860
|
+
depth = Math.max(0, Math.min(latest.ladder.length - 1, depth + (event.key === 'ArrowUp' ? 1 : -1)))
|
|
861
|
+
latest.index = depth
|
|
862
|
+
report('hover', latest.ladder[depth], latest.ladder, depth)
|
|
863
|
+
return
|
|
864
|
+
}
|
|
865
|
+
}, true)
|
|
866
|
+
|
|
867
|
+
window.addEventListener('scroll', sendScroll, true)
|
|
868
|
+
window.addEventListener('resize', sendScroll)
|
|
869
|
+
|
|
870
|
+
function capture(requestId) {
|
|
871
|
+
var root = doc.documentElement
|
|
872
|
+
var documentWidth = Math.max(1, Math.ceil(root.scrollWidth || root.clientWidth || window.innerWidth || 1))
|
|
873
|
+
var documentHeight = Math.max(1, Math.ceil(root.scrollHeight || root.clientHeight || window.innerHeight || 1))
|
|
874
|
+
var bounds = []
|
|
875
|
+
for (var i = 0; i < watched.length; i += 1) bounds.push(measure(watched[i]))
|
|
876
|
+
var surface = visibleSurface()
|
|
877
|
+
// A deck keeps every slide in the same document. Cloning all of them to PNG is
|
|
878
|
+
// both slow and wrong for a screenshot of the page in front: capture the surface
|
|
879
|
+
// subtree when one exists and let the panel place it at its document offset.
|
|
880
|
+
var rect = surface ? surface.getBoundingClientRect() : null
|
|
881
|
+
var scroll = scrollOf()
|
|
882
|
+
var width = rect ? Math.max(1, Math.ceil(rect.width)) : documentWidth
|
|
883
|
+
var height = rect ? Math.max(1, Math.ceil(rect.height)) : documentHeight
|
|
884
|
+
var imageRect = rect
|
|
885
|
+
? [rect.left + scroll[0], rect.top + scroll[1], width, height]
|
|
886
|
+
: [0, 0, width, height]
|
|
887
|
+
var target = surface || root
|
|
888
|
+
// The parent composes at no more than 1.5x, so a larger source only costs a DOM
|
|
889
|
+
// clone and PNG encode without adding pixels the delivered picture keeps.
|
|
890
|
+
var scale = Math.min(1.5, 1800 / Math.max(width, height))
|
|
891
|
+
var screenshot = window.modernScreenshot
|
|
892
|
+
if (!screenshot || typeof screenshot.domToDataUrl !== 'function') {
|
|
893
|
+
send({
|
|
894
|
+
figmark: MARKER,
|
|
895
|
+
kind: 'capture-result',
|
|
896
|
+
requestId: requestId,
|
|
897
|
+
status: 'failed',
|
|
898
|
+
reason: 'the screenshot renderer is unavailable'
|
|
899
|
+
})
|
|
900
|
+
return
|
|
901
|
+
}
|
|
902
|
+
screenshot.domToDataUrl(target, {
|
|
903
|
+
width: width,
|
|
904
|
+
height: height,
|
|
905
|
+
scale: scale,
|
|
906
|
+
backgroundColor: '#ffffff',
|
|
907
|
+
maximumCanvasSize: 4096,
|
|
908
|
+
timeout: 12000,
|
|
909
|
+
filter: function (node) {
|
|
910
|
+
if (!node || node.nodeType !== 1) return true
|
|
911
|
+
return !node.hasAttribute('data-dsh-figmark-style')
|
|
912
|
+
&& !node.hasAttribute('data-da-spot')
|
|
913
|
+
}
|
|
914
|
+
}).then(function (dataUrl) {
|
|
915
|
+
if (typeof dataUrl !== 'string' || dataUrl.length > ${String(MAX_LIVE_CAPTURE_DATA_URL)}) {
|
|
916
|
+
send({
|
|
917
|
+
figmark: MARKER,
|
|
918
|
+
kind: 'capture-result',
|
|
919
|
+
requestId: requestId,
|
|
920
|
+
status: 'failed',
|
|
921
|
+
reason: 'the screenshot is too large'
|
|
922
|
+
})
|
|
923
|
+
return
|
|
924
|
+
}
|
|
925
|
+
send({
|
|
926
|
+
figmark: MARKER,
|
|
927
|
+
kind: 'capture-result',
|
|
928
|
+
requestId: requestId,
|
|
929
|
+
status: 'ok',
|
|
930
|
+
dataUrl: dataUrl,
|
|
931
|
+
document: [documentWidth, documentHeight],
|
|
932
|
+
imageRect: imageRect,
|
|
933
|
+
viewport: [window.innerWidth || width, window.innerHeight || height],
|
|
934
|
+
scroll: scroll,
|
|
935
|
+
scale: scale,
|
|
936
|
+
surface: surface ? pathOf(surface) : null,
|
|
937
|
+
bounds: bounds
|
|
938
|
+
})
|
|
939
|
+
}, function (error) {
|
|
940
|
+
var reason = error && error.message ? String(error.message) : 'the page could not be captured'
|
|
941
|
+
send({
|
|
942
|
+
figmark: MARKER,
|
|
943
|
+
kind: 'capture-result',
|
|
944
|
+
requestId: requestId,
|
|
945
|
+
status: 'failed',
|
|
946
|
+
reason: reason.slice(0, 280)
|
|
947
|
+
})
|
|
948
|
+
})
|
|
949
|
+
}
|
|
950
|
+
|
|
951
|
+
window.addEventListener('message', function (event) {
|
|
952
|
+
var data = event.data
|
|
953
|
+
if (!data || typeof data !== 'object' || data.figmark !== MARKER) return
|
|
954
|
+
if (data.kind === 'mode') {
|
|
955
|
+
armed = !!data.armed
|
|
956
|
+
doc.documentElement.setAttribute('data-dsh-figmark-armed', armed ? 'true' : 'false')
|
|
957
|
+
if (!armed) {
|
|
958
|
+
latest = null
|
|
959
|
+
latestKey = ''
|
|
960
|
+
depth = 0
|
|
961
|
+
lastHover = ''
|
|
962
|
+
}
|
|
963
|
+
scheduleSurface()
|
|
964
|
+
return
|
|
965
|
+
}
|
|
966
|
+
if (data.kind === 'depth' && armed && latest && (data.delta === 1 || data.delta === -1)) {
|
|
967
|
+
event.preventDefault()
|
|
968
|
+
event.stopImmediatePropagation()
|
|
969
|
+
depth = Math.max(0, Math.min(latest.ladder.length - 1, latest.index + data.delta))
|
|
970
|
+
latest.index = depth
|
|
971
|
+
report('hover', latest.ladder[depth], latest.ladder, depth)
|
|
972
|
+
return
|
|
973
|
+
}
|
|
974
|
+
if (data.kind === 'watch' && Array.isArray(data.selectors)) {
|
|
975
|
+
watched = []
|
|
976
|
+
for (var i = 0; i < data.selectors.length && watched.length < 256; i += 1) {
|
|
977
|
+
var selector = data.selectors[i]
|
|
978
|
+
if (typeof selector === 'string' && selector.length > 0 && selector.length <= 2048
|
|
979
|
+
&& watched.indexOf(selector) < 0) watched.push(selector)
|
|
980
|
+
}
|
|
981
|
+
lastBounds = ''
|
|
982
|
+
sendBounds(true)
|
|
983
|
+
return
|
|
984
|
+
}
|
|
985
|
+
if (data.kind === 'reveal' && typeof data.requestId === 'string' && data.requestId.length <= 128) {
|
|
986
|
+
var requestedSelector = typeof data.selector === 'string'
|
|
987
|
+
&& data.selector.length > 0 && data.selector.length <= 2048 ? data.selector : null
|
|
988
|
+
var requestedSurface = typeof data.surface === 'string'
|
|
989
|
+
&& data.surface.length > 0 && data.surface.length <= 2048 ? data.surface : null
|
|
990
|
+
var requestedBox = Array.isArray(data.bbox) && data.bbox.length === 4 ? data.bbox : [0, 0, 0, 0]
|
|
991
|
+
reveal(data.requestId, requestedSelector, requestedSurface, requestedBox)
|
|
992
|
+
return
|
|
993
|
+
}
|
|
994
|
+
if (data.kind === 'capture' && typeof data.requestId === 'string'
|
|
995
|
+
&& data.requestId.length > 0 && data.requestId.length <= 128) {
|
|
996
|
+
capture(data.requestId)
|
|
997
|
+
return
|
|
998
|
+
}
|
|
999
|
+
if (data.kind === 'release-reveal') {
|
|
1000
|
+
revealSeq += 1
|
|
1001
|
+
restoreHidden()
|
|
1002
|
+
}
|
|
1003
|
+
})
|
|
1004
|
+
|
|
1005
|
+
if (window.MutationObserver) {
|
|
1006
|
+
new window.MutationObserver(function () {
|
|
1007
|
+
scheduleBounds()
|
|
1008
|
+
scheduleSurface()
|
|
1009
|
+
}).observe(doc.documentElement, {
|
|
1010
|
+
subtree: true,
|
|
1011
|
+
childList: true,
|
|
1012
|
+
attributes: true,
|
|
1013
|
+
attributeFilter: ['class', 'style', 'hidden', 'aria-hidden', 'aria-current']
|
|
1014
|
+
})
|
|
1015
|
+
}
|
|
1016
|
+
window.addEventListener('hashchange', scheduleSurface)
|
|
1017
|
+
window.addEventListener('popstate', scheduleSurface)
|
|
1018
|
+
window.setInterval(function () {
|
|
1019
|
+
sendBounds(false)
|
|
1020
|
+
sendSurface(false)
|
|
1021
|
+
}, 250)
|
|
1022
|
+
|
|
1023
|
+
send({ figmark: MARKER, kind: 'ready', scroll: scrollOf() })
|
|
1024
|
+
scheduleSurface()
|
|
1025
|
+
})()
|
|
1026
|
+
`;
|
|
1027
|
+
//#endregion
|
|
1028
|
+
//#region src/host/live-proxy.ts
|
|
1029
|
+
/**
|
|
1030
|
+
* A live page, served from this machine so the panel can frame it.
|
|
1031
|
+
*
|
|
1032
|
+
* The panel cannot simply point a frame at somebody's dev server. A frame is
|
|
1033
|
+
* usually refused outright by the page's own `X-Frame-Options` or CSP, and even
|
|
1034
|
+
* when it loads, a cross-origin frame is opaque: the panel can look at it and
|
|
1035
|
+
* nothing more. Neither is acceptable — the reader has to point at elements in
|
|
1036
|
+
* the page, which takes a script running inside it.
|
|
1037
|
+
*
|
|
1038
|
+
* So the page is fetched by the Host instead. The frame points at
|
|
1039
|
+
* `http://127.0.0.1:<port>`, that origin is the proxy's, and the proxy is a
|
|
1040
|
+
* pass-through with three edits: policies that would forbid framing or the
|
|
1041
|
+
* injected script are dropped (headers and HTML meta tags alike), same-origin
|
|
1042
|
+
* redirects are kept on the proxy origin, and the script tag is put in front of
|
|
1043
|
+
* `</body>` in HTML. Everything else — methods, bodies, assets, the dev server's
|
|
1044
|
+
* own websocket-free HMR client — crosses untouched, which is what keeps a
|
|
1045
|
+
* script-driven page behaving like the page it is.
|
|
1046
|
+
*
|
|
1047
|
+
* Only this machine's own servers are reachable through it. A proxy that
|
|
1048
|
+
* accepted any origin would be an open relay running inside the app, so a
|
|
1049
|
+
* target has to be loopback by name *and* be plain `http:`: the plugin's route
|
|
1050
|
+
* is not the place to invent TLS to a local server.
|
|
1051
|
+
* @module dsh-figmark/host/live-proxy
|
|
1052
|
+
*/
|
|
1053
|
+
/** What the frame shows when the page stopped answering. */
|
|
1054
|
+
const DOWN = "the page is not answering";
|
|
1055
|
+
/** What the frame shows when a page is too big to rewrite. */
|
|
1056
|
+
const TOO_BIG = `the page is larger than ${MAX_HTML_BYTES} bytes, so the picker was not injected`;
|
|
1057
|
+
/**
|
|
1058
|
+
* Headers the proxy always drops from an answer.
|
|
1059
|
+
*
|
|
1060
|
+
* The first two would forbid the injected script or the frame itself; the third
|
|
1061
|
+
* is the pre-CSP way of saying the same thing. Nothing else is touched: the
|
|
1062
|
+
* page's own policy on its assets is its business.
|
|
1063
|
+
*/
|
|
1064
|
+
const STRIPPED = [
|
|
1065
|
+
"content-security-policy",
|
|
1066
|
+
"content-security-policy-report-only",
|
|
1067
|
+
"x-frame-options"
|
|
1068
|
+
];
|
|
1069
|
+
/** The host names that mean this machine. */
|
|
1070
|
+
const LOOPBACK = /* @__PURE__ */ new Set([
|
|
1071
|
+
"localhost",
|
|
1072
|
+
"127.0.0.1",
|
|
1073
|
+
"[::1]"
|
|
1074
|
+
]);
|
|
1075
|
+
/**
|
|
1076
|
+
* Serve one page from this machine.
|
|
1077
|
+
* @param url - the address the reader typed, e.g. `http://localhost:5173`.
|
|
1078
|
+
* @returns the proxy, already listening.
|
|
1079
|
+
* @throws {Error} when the address is not a loopback `http:` page.
|
|
1080
|
+
*/
|
|
1081
|
+
async function openLive(url) {
|
|
1082
|
+
if (url.protocol !== "http:") throw new Error(`only a server on this machine can be opened, and ${url.protocol} is not http`);
|
|
1083
|
+
if (!LOOPBACK.has(url.hostname)) throw new Error(`only a server on this machine can be opened, and ${url.hostname} is not one of its loopback names`);
|
|
1084
|
+
const target = new URL(url.href);
|
|
1085
|
+
const sockets = /* @__PURE__ */ new Set();
|
|
1086
|
+
let proxyOrigin = "";
|
|
1087
|
+
const server = createServer((req, res) => {
|
|
1088
|
+
serve(target, proxyOrigin, req, res);
|
|
1089
|
+
});
|
|
1090
|
+
server.on("upgrade", (req, socket, head) => {
|
|
1091
|
+
upgrade(target, req, socket, head, sockets);
|
|
1092
|
+
});
|
|
1093
|
+
server.on("connection", (socket) => {
|
|
1094
|
+
sockets.add(socket);
|
|
1095
|
+
socket.on("close", () => {
|
|
1096
|
+
sockets.delete(socket);
|
|
1097
|
+
});
|
|
1098
|
+
});
|
|
1099
|
+
await listen(server);
|
|
1100
|
+
const { port } = server.address();
|
|
1101
|
+
proxyOrigin = `http://127.0.0.1:${port}`;
|
|
1102
|
+
let closed = false;
|
|
1103
|
+
return {
|
|
1104
|
+
url: `${proxyOrigin}${target.pathname}${target.search}`,
|
|
1105
|
+
close() {
|
|
1106
|
+
if (closed) return;
|
|
1107
|
+
closed = true;
|
|
1108
|
+
for (const socket of sockets) socket.destroy();
|
|
1109
|
+
sockets.clear();
|
|
1110
|
+
server.close();
|
|
1111
|
+
}
|
|
1112
|
+
};
|
|
1113
|
+
}
|
|
1114
|
+
/**
|
|
1115
|
+
* Hand one upgrade request to the target, socket to socket.
|
|
1116
|
+
* @param target - the page being served.
|
|
1117
|
+
* @param req - the upgrade request from the frame.
|
|
1118
|
+
* @param socket - the frame's own socket.
|
|
1119
|
+
* @param head - bytes the client already sent past the headers.
|
|
1120
|
+
* @param sockets - every socket this proxy holds, so closing it closes these too.
|
|
1121
|
+
*/
|
|
1122
|
+
function upgrade(target, req, socket, head, sockets) {
|
|
1123
|
+
const upstream = connect(target.port === "" ? 80 : Number(target.port), target.hostname, () => {
|
|
1124
|
+
const lines = [`${req.method} ${req.url} HTTP/1.1`];
|
|
1125
|
+
for (let i = 0; i < req.rawHeaders.length; i += 2) {
|
|
1126
|
+
const name = req.rawHeaders[i];
|
|
1127
|
+
if (name.toLowerCase() === "host") continue;
|
|
1128
|
+
lines.push(`${name}: ${req.rawHeaders[i + 1]}`);
|
|
1129
|
+
}
|
|
1130
|
+
lines.push(`host: ${target.host}`);
|
|
1131
|
+
upstream.write(`${lines.join("\r\n")}\r\n\r\n`);
|
|
1132
|
+
if (head.length > 0) upstream.write(head);
|
|
1133
|
+
upstream.pipe(socket);
|
|
1134
|
+
socket.pipe(upstream);
|
|
1135
|
+
});
|
|
1136
|
+
sockets.add(upstream);
|
|
1137
|
+
upstream.on("close", () => {
|
|
1138
|
+
sockets.delete(upstream);
|
|
1139
|
+
});
|
|
1140
|
+
upstream.on("error", () => {
|
|
1141
|
+
socket.destroy();
|
|
1142
|
+
});
|
|
1143
|
+
socket.on("error", () => {
|
|
1144
|
+
upstream.destroy();
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Bind a server to a free port on the loopback interface.
|
|
1149
|
+
* @param server - the server to bind.
|
|
1150
|
+
*/
|
|
1151
|
+
async function listen(server) {
|
|
1152
|
+
await new Promise((resolve, reject) => {
|
|
1153
|
+
server.once("error", reject);
|
|
1154
|
+
server.listen(0, "127.0.0.1", () => {
|
|
1155
|
+
resolve();
|
|
1156
|
+
});
|
|
1157
|
+
});
|
|
1158
|
+
}
|
|
1159
|
+
/**
|
|
1160
|
+
* Answer one request on the proxy.
|
|
1161
|
+
* @param target - the page being served.
|
|
1162
|
+
* @param proxyOrigin - this proxy's own origin, used for same-origin redirects.
|
|
1163
|
+
* @param req - the request from the frame.
|
|
1164
|
+
* @param res - the answer to it.
|
|
1165
|
+
*/
|
|
1166
|
+
function serve(target, proxyOrigin, req, res) {
|
|
1167
|
+
if (req.method === "GET" && req.url === "/__dsh_figmark_picker.js") {
|
|
1168
|
+
sendPicker(res);
|
|
1169
|
+
return;
|
|
1170
|
+
}
|
|
1171
|
+
forward(target, proxyOrigin, req, res);
|
|
1172
|
+
}
|
|
1173
|
+
/**
|
|
1174
|
+
* Answer the request for the picker itself.
|
|
1175
|
+
* @param res - the answer to the frame.
|
|
1176
|
+
*/
|
|
1177
|
+
function sendPicker(res) {
|
|
1178
|
+
res.writeHead(200, {
|
|
1179
|
+
"content-type": "application/javascript; charset=utf-8",
|
|
1180
|
+
"cache-control": "no-store",
|
|
1181
|
+
"content-length": Buffer.byteLength(PICKER_SCRIPT)
|
|
1182
|
+
});
|
|
1183
|
+
res.end(PICKER_SCRIPT);
|
|
1184
|
+
}
|
|
1185
|
+
/**
|
|
1186
|
+
* Ask the target for what the frame asked for, and hand the answer back.
|
|
1187
|
+
*
|
|
1188
|
+
* A refused connection, a reset and a name that does not resolve all arrive on
|
|
1189
|
+
* the request's `error` event, which is the only place they can be turned into
|
|
1190
|
+
* an answer: without it, the frame would sit on a connection that never starts.
|
|
1191
|
+
* @param target - the page being served.
|
|
1192
|
+
* @param proxyOrigin - this proxy's own origin.
|
|
1193
|
+
* @param req - the request from the frame.
|
|
1194
|
+
* @param res - the answer to it.
|
|
1195
|
+
*/
|
|
1196
|
+
function forward(target, proxyOrigin, req, res) {
|
|
1197
|
+
const headers = { ...req.headers };
|
|
1198
|
+
headers["host"] = target.host;
|
|
1199
|
+
delete headers["accept-encoding"];
|
|
1200
|
+
const upstream = request(target, {
|
|
1201
|
+
method: req.method,
|
|
1202
|
+
path: req.url,
|
|
1203
|
+
headers
|
|
1204
|
+
});
|
|
1205
|
+
upstream.on("response", (answer) => {
|
|
1206
|
+
returnAnswer(target, proxyOrigin, res, answer);
|
|
1207
|
+
});
|
|
1208
|
+
upstream.on("error", () => {
|
|
1209
|
+
fail(res, DOWN);
|
|
1210
|
+
});
|
|
1211
|
+
req.on("error", () => {
|
|
1212
|
+
upstream.destroy();
|
|
1213
|
+
});
|
|
1214
|
+
req.pipe(upstream);
|
|
1215
|
+
}
|
|
1216
|
+
/**
|
|
1217
|
+
* Hand one answer from the target back to the frame.
|
|
1218
|
+
* @param target - the page being served.
|
|
1219
|
+
* @param proxyOrigin - this proxy's own origin.
|
|
1220
|
+
* @param res - the answer to the frame.
|
|
1221
|
+
* @param answer - the target's answer.
|
|
1222
|
+
*/
|
|
1223
|
+
function returnAnswer(target, proxyOrigin, res, answer) {
|
|
1224
|
+
if (isHtml(answer.headers)) {
|
|
1225
|
+
holdHtml(target, proxyOrigin, res, answer);
|
|
1226
|
+
return;
|
|
1227
|
+
}
|
|
1228
|
+
res.writeHead(statusOf(answer), withoutStripped(answer.headers, target, proxyOrigin));
|
|
1229
|
+
answer.on("error", () => {
|
|
1230
|
+
res.destroy();
|
|
1231
|
+
});
|
|
1232
|
+
answer.pipe(res);
|
|
1233
|
+
}
|
|
1234
|
+
/**
|
|
1235
|
+
* Buffer an HTML page, put the picker tag in it, and send it on.
|
|
1236
|
+
* @param target - the page being served.
|
|
1237
|
+
* @param proxyOrigin - this proxy's own origin.
|
|
1238
|
+
* @param res - the answer to the frame.
|
|
1239
|
+
* @param answer - the target's answer.
|
|
1240
|
+
*/
|
|
1241
|
+
function holdHtml(target, proxyOrigin, res, answer) {
|
|
1242
|
+
const chunks = [];
|
|
1243
|
+
let size = 0;
|
|
1244
|
+
answer.on("data", (chunk) => {
|
|
1245
|
+
size += chunk.length;
|
|
1246
|
+
if (size > 8388608) {
|
|
1247
|
+
answer.destroy();
|
|
1248
|
+
fail(res, TOO_BIG);
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
chunks.push(chunk);
|
|
1252
|
+
});
|
|
1253
|
+
answer.on("error", () => {
|
|
1254
|
+
res.destroy();
|
|
1255
|
+
});
|
|
1256
|
+
answer.on("end", () => {
|
|
1257
|
+
const body = injectPicker(stripCspMeta(Buffer.concat(chunks).toString("utf8")), PICKER_TAG);
|
|
1258
|
+
const headers = withoutStripped(answer.headers, target, proxyOrigin);
|
|
1259
|
+
delete headers["content-length"];
|
|
1260
|
+
delete headers["transfer-encoding"];
|
|
1261
|
+
headers["content-length"] = Buffer.byteLength(body);
|
|
1262
|
+
res.writeHead(statusOf(answer), headers);
|
|
1263
|
+
res.end(body);
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
/**
|
|
1267
|
+
* Whether an answer is a page the picker has to be injected into.
|
|
1268
|
+
* @param headers - the target's headers.
|
|
1269
|
+
* @returns true for an HTML body, whatever else the type says.
|
|
1270
|
+
*/
|
|
1271
|
+
function isHtml(headers) {
|
|
1272
|
+
const type = headers["content-type"];
|
|
1273
|
+
return typeof type === "string" && type.toLowerCase().startsWith("text/html");
|
|
1274
|
+
}
|
|
1275
|
+
/**
|
|
1276
|
+
* Copy headers, dropping the ones that would keep the frame from loading the page.
|
|
1277
|
+
* @param source - the target's headers.
|
|
1278
|
+
* @param target - the page being served.
|
|
1279
|
+
* @param proxyOrigin - this proxy's own origin.
|
|
1280
|
+
* @returns the headers to send to the frame.
|
|
1281
|
+
*/
|
|
1282
|
+
function withoutStripped(source, target, proxyOrigin) {
|
|
1283
|
+
const headers = { ...source };
|
|
1284
|
+
for (const name of STRIPPED) delete headers[name];
|
|
1285
|
+
const location = source.location;
|
|
1286
|
+
if (location !== void 0) try {
|
|
1287
|
+
const redirect = new URL(location, target);
|
|
1288
|
+
if (redirect.origin === target.origin) headers["location"] = `${proxyOrigin}${redirect.pathname}${redirect.search}${redirect.hash}`;
|
|
1289
|
+
} catch {}
|
|
1290
|
+
return headers;
|
|
1291
|
+
}
|
|
1292
|
+
/**
|
|
1293
|
+
* The status code of an answer.
|
|
1294
|
+
*
|
|
1295
|
+
* The `response` event only fires once a status line has been parsed, so the code
|
|
1296
|
+
* is always there; the type is optional because a message can be built by hand.
|
|
1297
|
+
* @param answer - the target's answer.
|
|
1298
|
+
* @returns its status code.
|
|
1299
|
+
*/
|
|
1300
|
+
function statusOf(answer) {
|
|
1301
|
+
return answer.statusCode;
|
|
1302
|
+
}
|
|
1303
|
+
/**
|
|
1304
|
+
* Answer the frame with a sentence instead of a page.
|
|
1305
|
+
* @param res - the answer to the frame.
|
|
1306
|
+
* @param message - the sentence to show.
|
|
1307
|
+
*/
|
|
1308
|
+
function fail(res, message) {
|
|
1309
|
+
/* v8 ignore next 8 -- both callers answer before they have written a status line;
|
|
1310
|
+
the branch stays because a truncated answer is the only honest thing to send if
|
|
1311
|
+
that ever stops being true. */
|
|
1312
|
+
if (res.headersSent) {
|
|
1313
|
+
res.destroy();
|
|
1314
|
+
return;
|
|
1315
|
+
}
|
|
1316
|
+
res.writeHead(502, {
|
|
1317
|
+
"content-type": "text/plain; charset=utf-8",
|
|
1318
|
+
"cache-control": "no-store",
|
|
1319
|
+
"content-length": Buffer.byteLength(message)
|
|
1320
|
+
});
|
|
1321
|
+
res.end(message);
|
|
1322
|
+
}
|
|
256
1323
|
//#endregion
|
|
257
1324
|
//#region src/shared/annotation.ts
|
|
258
1325
|
/**
|
|
@@ -278,6 +1345,24 @@ const WEIGHT_MIN = .5;
|
|
|
278
1345
|
function clampWeight(value) {
|
|
279
1346
|
return Math.min(8, Math.max(WEIGHT_MIN, value));
|
|
280
1347
|
}
|
|
1348
|
+
/**
|
|
1349
|
+
* The kinds in the order the rail hands them out, which is the order the list reads
|
|
1350
|
+
* in.
|
|
1351
|
+
*
|
|
1352
|
+
* The list is gathered by kind rather than left in the order the marks were drawn,
|
|
1353
|
+
* because its numbers are per kind: drawn in an evening of circling and dropping
|
|
1354
|
+
* pins, a list of six read 1, 2, 1, 1, 2, 3 — the count kept starting over wherever
|
|
1355
|
+
* the hand happened to change tool. Gathered, the same six read 1, 2, 3, 1, 1, 2,
|
|
1356
|
+
* and each run of numbers belongs to one shape.
|
|
1357
|
+
*/
|
|
1358
|
+
const MARK_KIND_ORDER = [
|
|
1359
|
+
"arrow",
|
|
1360
|
+
"rect",
|
|
1361
|
+
"ellipse",
|
|
1362
|
+
"pen",
|
|
1363
|
+
"point",
|
|
1364
|
+
"element"
|
|
1365
|
+
];
|
|
281
1366
|
//#endregion
|
|
282
1367
|
//#region src/host/route-http.ts
|
|
283
1368
|
/**
|
|
@@ -288,7 +1373,7 @@ function clampWeight(value) {
|
|
|
288
1373
|
* this is refused before it is buffered, which is what keeps a hostile body from
|
|
289
1374
|
* being an allocation.
|
|
290
1375
|
*/
|
|
291
|
-
const MAX_BODY_BYTES = 24 * 1024 * 1024 * 2;
|
|
1376
|
+
const MAX_BODY_BYTES$1 = 24 * 1024 * 1024 * 2;
|
|
292
1377
|
/**
|
|
293
1378
|
* Whether a request carries the plugin guard and a same-origin posture.
|
|
294
1379
|
*
|
|
@@ -342,7 +1427,7 @@ async function readBody(req) {
|
|
|
342
1427
|
for await (const chunk of req) {
|
|
343
1428
|
const buffer = chunk;
|
|
344
1429
|
total += buffer.length;
|
|
345
|
-
if (total > MAX_BODY_BYTES) throw new Error(`request body exceeds ${MAX_BODY_BYTES} bytes`);
|
|
1430
|
+
if (total > MAX_BODY_BYTES$1) throw new Error(`request body exceeds ${MAX_BODY_BYTES$1} bytes`);
|
|
346
1431
|
chunks.push(buffer);
|
|
347
1432
|
}
|
|
348
1433
|
return Buffer.concat(chunks).toString("utf8");
|
|
@@ -380,6 +1465,174 @@ function readReview(body) {
|
|
|
380
1465
|
};
|
|
381
1466
|
}
|
|
382
1467
|
//#endregion
|
|
1468
|
+
//#region src/host/service-discovery.ts
|
|
1469
|
+
/**
|
|
1470
|
+
* Find local HTTP services without exposing an open network relay.
|
|
1471
|
+
*
|
|
1472
|
+
* The scan is deliberately bounded: loopback only, a short list of common ports,
|
|
1473
|
+
* ports named by the current workspace's package scripts, and recently used URLs.
|
|
1474
|
+
* It never scans 1-65535 and never follows a candidate to another host.
|
|
1475
|
+
* @module dsh-figmark/host/service-discovery
|
|
1476
|
+
*/
|
|
1477
|
+
/** Ports common development servers use when the package does not say. */
|
|
1478
|
+
const COMMON_PORTS = [
|
|
1479
|
+
3e3,
|
|
1480
|
+
3001,
|
|
1481
|
+
4173,
|
|
1482
|
+
4321,
|
|
1483
|
+
5173,
|
|
1484
|
+
5174,
|
|
1485
|
+
8e3,
|
|
1486
|
+
8080,
|
|
1487
|
+
8888,
|
|
1488
|
+
8898
|
|
1489
|
+
];
|
|
1490
|
+
/** Most pages one scan reads at once. */
|
|
1491
|
+
const CONCURRENCY = 8;
|
|
1492
|
+
/** Most candidates one scan will consider. */
|
|
1493
|
+
const MAX_CANDIDATES = 32;
|
|
1494
|
+
/** Longest title retained from a page. */
|
|
1495
|
+
const MAX_TITLE = 80;
|
|
1496
|
+
/** Most body bytes one probe will read before closing the response. */
|
|
1497
|
+
const MAX_BODY_BYTES = 64 * 1024;
|
|
1498
|
+
/** Absolute lifetime of one probe, including connect, response, and body. */
|
|
1499
|
+
const PROBE_TIMEOUT_MS = 450;
|
|
1500
|
+
/** Whether a URL is plain HTTP on this machine. */
|
|
1501
|
+
function loopback(url) {
|
|
1502
|
+
return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1" || url.hostname === "[::1]");
|
|
1503
|
+
}
|
|
1504
|
+
/** A normalized candidate URL, or undefined when it is not a loopback HTTP page. */
|
|
1505
|
+
function candidateOf(value, source) {
|
|
1506
|
+
try {
|
|
1507
|
+
const url = new URL(value);
|
|
1508
|
+
return loopback(url) ? {
|
|
1509
|
+
url: url.href,
|
|
1510
|
+
source
|
|
1511
|
+
} : void 0;
|
|
1512
|
+
} catch {
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
}
|
|
1516
|
+
/** Ports explicitly named by the workspace's package scripts. */
|
|
1517
|
+
async function workspacePorts(cwd) {
|
|
1518
|
+
if (cwd === void 0) return [];
|
|
1519
|
+
try {
|
|
1520
|
+
const raw = JSON.parse(await readFile(join(cwd, "package.json"), "utf8"));
|
|
1521
|
+
return [...Object.values(raw.scripts ?? {}).filter((value) => typeof value === "string").join(" ").matchAll(/(?:--port(?:=|\s+)|PORT=)([1-9][0-9]{1,4})/gu)].map((match) => Number(match[1])).filter((port) => Number.isInteger(port) && port > 0 && port < 65536);
|
|
1522
|
+
} catch {
|
|
1523
|
+
return [];
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
/** Extract a short title from the beginning of an HTML answer. */
|
|
1527
|
+
function titleOf(html) {
|
|
1528
|
+
const match = /<title\b[^>]*>([\s\S]*?)<\/title>/iu.exec(html);
|
|
1529
|
+
if (match === null) return void 0;
|
|
1530
|
+
const title = match[0].replace(/^<title\b[^>]*>/iu, "").replace(/<\/title>$/iu, "").replace(/<[^>]+>/gu, " ").replace(/&/gu, "&").replace(/</gu, "<").replace(/>/gu, ">").replace(/"/gu, "\"").replace(/'/gu, "'").replace(/\s+/gu, " ").trim();
|
|
1531
|
+
return title === "" ? void 0 : title.slice(0, MAX_TITLE);
|
|
1532
|
+
}
|
|
1533
|
+
/** Ask one address for its title; undefined means nothing answered there. */
|
|
1534
|
+
function probe(candidate) {
|
|
1535
|
+
return new Promise((resolve) => {
|
|
1536
|
+
const url = new URL(candidate.url);
|
|
1537
|
+
let settled = false;
|
|
1538
|
+
let deadline;
|
|
1539
|
+
const finish = (answer) => {
|
|
1540
|
+
if (settled) return;
|
|
1541
|
+
settled = true;
|
|
1542
|
+
clearTimeout(deadline);
|
|
1543
|
+
resolve(answer);
|
|
1544
|
+
};
|
|
1545
|
+
const req = request({
|
|
1546
|
+
protocol: "http:",
|
|
1547
|
+
hostname: url.hostname,
|
|
1548
|
+
port: url.port === "" ? 80 : Number(url.port),
|
|
1549
|
+
path: `${url.pathname}${url.search}`,
|
|
1550
|
+
method: "GET",
|
|
1551
|
+
headers: { accept: "text/html,application/xhtml+xml" }
|
|
1552
|
+
}, (res) => {
|
|
1553
|
+
const chunks = [];
|
|
1554
|
+
let bytes = 0;
|
|
1555
|
+
const finishResponse = () => {
|
|
1556
|
+
/* v8 ignore next -- Node populates statusCode before emitting `response`. */
|
|
1557
|
+
const status = res.statusCode ?? 0;
|
|
1558
|
+
if (status < 200 || status >= 500) {
|
|
1559
|
+
finish(void 0);
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
const title = titleOf(Buffer.concat(chunks).toString("utf8"));
|
|
1563
|
+
finish({
|
|
1564
|
+
url: candidate.url,
|
|
1565
|
+
source: candidate.source,
|
|
1566
|
+
...title === void 0 ? {} : { title }
|
|
1567
|
+
});
|
|
1568
|
+
};
|
|
1569
|
+
res.on("data", (chunk) => {
|
|
1570
|
+
const kept = chunk.subarray(0, MAX_BODY_BYTES - bytes);
|
|
1571
|
+
chunks.push(kept);
|
|
1572
|
+
bytes += kept.byteLength;
|
|
1573
|
+
if (bytes < MAX_BODY_BYTES) return;
|
|
1574
|
+
finishResponse();
|
|
1575
|
+
res.destroy();
|
|
1576
|
+
});
|
|
1577
|
+
res.on("end", finishResponse);
|
|
1578
|
+
res.on("error", () => {
|
|
1579
|
+
finish(void 0);
|
|
1580
|
+
});
|
|
1581
|
+
});
|
|
1582
|
+
deadline = setTimeout(() => {
|
|
1583
|
+
finish(void 0);
|
|
1584
|
+
req.destroy();
|
|
1585
|
+
}, PROBE_TIMEOUT_MS);
|
|
1586
|
+
req.on("error", () => {
|
|
1587
|
+
finish(void 0);
|
|
1588
|
+
});
|
|
1589
|
+
req.end();
|
|
1590
|
+
});
|
|
1591
|
+
}
|
|
1592
|
+
/** Probe candidates with a fixed number of workers. */
|
|
1593
|
+
async function probeAll(candidates) {
|
|
1594
|
+
const answers = [];
|
|
1595
|
+
let next = 0;
|
|
1596
|
+
const work = async () => {
|
|
1597
|
+
for (;;) {
|
|
1598
|
+
const index = next;
|
|
1599
|
+
next += 1;
|
|
1600
|
+
const candidate = candidates[index];
|
|
1601
|
+
if (candidate === void 0) return;
|
|
1602
|
+
const answer = await probe(candidate);
|
|
1603
|
+
if (answer !== void 0) answers.push(answer);
|
|
1604
|
+
}
|
|
1605
|
+
};
|
|
1606
|
+
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, candidates.length) }, work));
|
|
1607
|
+
return answers;
|
|
1608
|
+
}
|
|
1609
|
+
/**
|
|
1610
|
+
* Discover reachable local HTTP services.
|
|
1611
|
+
* @param request - the browser's session and recent addresses.
|
|
1612
|
+
* @param sessionCwd - resolves that session's workspace.
|
|
1613
|
+
* @returns the reachable services, de-duplicated by URL.
|
|
1614
|
+
*/
|
|
1615
|
+
async function discoverServices(request, sessionCwd) {
|
|
1616
|
+
const ports = await workspacePorts(await sessionCwd(request.sessionId));
|
|
1617
|
+
const candidates = [];
|
|
1618
|
+
const seen = /* @__PURE__ */ new Set();
|
|
1619
|
+
const add = (candidate) => {
|
|
1620
|
+
if (candidate === void 0 || seen.has(candidate.url) || candidates.length >= MAX_CANDIDATES) return;
|
|
1621
|
+
seen.add(candidate.url);
|
|
1622
|
+
candidates.push(candidate);
|
|
1623
|
+
};
|
|
1624
|
+
for (const recent of request.recent ?? []) add(candidateOf(recent, "recent"));
|
|
1625
|
+
for (const port of ports) add(candidateOf(`http://127.0.0.1:${String(port)}/`, "workspace"));
|
|
1626
|
+
for (const port of COMMON_PORTS) add(candidateOf(`http://127.0.0.1:${String(port)}/`, "common"));
|
|
1627
|
+
const services = await probeAll(candidates);
|
|
1628
|
+
const rank = {
|
|
1629
|
+
recent: 0,
|
|
1630
|
+
workspace: 1,
|
|
1631
|
+
common: 2
|
|
1632
|
+
};
|
|
1633
|
+
return services.sort((left, right) => rank[left.source] - rank[right.source] || new URL(left.url).port.localeCompare(new URL(right.url).port, void 0, { numeric: true }));
|
|
1634
|
+
}
|
|
1635
|
+
//#endregion
|
|
383
1636
|
//#region src/host/store-schema.ts
|
|
384
1637
|
/**
|
|
385
1638
|
* The wire schema of one annotation document.
|
|
@@ -398,6 +1651,8 @@ function readReview(body) {
|
|
|
398
1651
|
*/
|
|
399
1652
|
/** Longest element selector one anchor may carry. */
|
|
400
1653
|
const MAX_SELECTOR_CHARS = 400;
|
|
1654
|
+
/** Longest class list one anchor may carry. */
|
|
1655
|
+
const MAX_ANCHOR_CLASS_CHARS = 160;
|
|
401
1656
|
/**
|
|
402
1657
|
* A note as it is stored: trimmed, and refused when it is longer than a paragraph.
|
|
403
1658
|
*
|
|
@@ -417,15 +1672,6 @@ function readNote(value, what, max) {
|
|
|
417
1672
|
const trimmed = value.trim();
|
|
418
1673
|
return trimmed === "" ? void 0 : trimmed;
|
|
419
1674
|
}
|
|
420
|
-
/** Kinds a mark may declare. */
|
|
421
|
-
const MARK_KINDS = [
|
|
422
|
-
"arrow",
|
|
423
|
-
"rect",
|
|
424
|
-
"ellipse",
|
|
425
|
-
"pen",
|
|
426
|
-
"point",
|
|
427
|
-
"element"
|
|
428
|
-
];
|
|
429
1675
|
/**
|
|
430
1676
|
* Whether a value is a finite figure coordinate pair.
|
|
431
1677
|
* @param value - candidate value from the wire.
|
|
@@ -457,6 +1703,7 @@ function readAnchor(value) {
|
|
|
457
1703
|
tag: raw["tag"],
|
|
458
1704
|
bbox: raw["bbox"],
|
|
459
1705
|
...typeof raw["id"] === "string" ? { id: raw["id"] } : {},
|
|
1706
|
+
...typeof raw["class"] === "string" ? { class: raw["class"].slice(0, MAX_ANCHOR_CLASS_CHARS) } : {},
|
|
460
1707
|
...typeof raw["title"] === "string" ? { title: raw["title"] } : {},
|
|
461
1708
|
...typeof raw["text"] === "string" ? { text: raw["text"] } : {},
|
|
462
1709
|
...typeof raw["selector"] === "string" ? { selector: raw["selector"].slice(0, MAX_SELECTOR_CHARS) } : {}
|
|
@@ -527,7 +1774,7 @@ function readMark(value, index, figure) {
|
|
|
527
1774
|
if (typeof value !== "object" || value === null) throw new Error(`annotation mark ${index} must be an object`);
|
|
528
1775
|
const raw = value;
|
|
529
1776
|
const kind = raw["kind"];
|
|
530
|
-
if (typeof kind !== "string" || !
|
|
1777
|
+
if (typeof kind !== "string" || !MARK_KIND_ORDER.includes(kind)) throw new Error(`annotation mark ${index} has unknown kind ${JSON.stringify(kind)}`);
|
|
531
1778
|
if (typeof raw["id"] !== "string" || raw["id"] === "") throw new Error(`annotation mark ${index} needs an id`);
|
|
532
1779
|
if (typeof raw["color"] !== "string") throw new Error(`annotation mark ${index} needs a color`);
|
|
533
1780
|
const points = raw["points"];
|
|
@@ -536,6 +1783,7 @@ function readMark(value, index, figure) {
|
|
|
536
1783
|
if (page !== void 0 && figure.width !== void 0) throw new Error(`annotation mark ${index} names a page, but its figure is one surface`);
|
|
537
1784
|
if (page !== void 0 && figure.pageCount !== void 0 && page > figure.pageCount) throw new Error(`annotation mark ${index} is on page ${page} of a ${figure.pageCount}-page document`);
|
|
538
1785
|
const anchor = readAnchor(raw["anchor"]);
|
|
1786
|
+
const surface = typeof raw["surface"] === "string" ? raw["surface"].slice(0, MAX_SELECTOR_CHARS) : void 0;
|
|
539
1787
|
const rotation = readRotation(raw["rotation"]);
|
|
540
1788
|
const weight = readWeight(raw["weight"]);
|
|
541
1789
|
const detail = readNote(raw["detail"], "mark note", MAX_DETAIL_CHARS);
|
|
@@ -546,6 +1794,7 @@ function readMark(value, index, figure) {
|
|
|
546
1794
|
points,
|
|
547
1795
|
...detail === void 0 ? {} : { detail },
|
|
548
1796
|
...page === void 0 ? {} : { page },
|
|
1797
|
+
...surface === void 0 ? {} : { surface },
|
|
549
1798
|
...rotation === void 0 ? {} : { rotation },
|
|
550
1799
|
...weight === void 0 ? {} : { weight },
|
|
551
1800
|
...anchor === void 0 ? {} : { anchor }
|
|
@@ -1022,6 +2271,10 @@ async function allSidecarNames(figurePath) {
|
|
|
1022
2271
|
}
|
|
1023
2272
|
//#endregion
|
|
1024
2273
|
//#region src/host/routes.ts
|
|
2274
|
+
/** The route a live page is opened and closed on. */
|
|
2275
|
+
const LIVE_PATH = `${ROUTE_PREFIX}${LIVE_ROUTE}`;
|
|
2276
|
+
/** The route that probes bounded loopback candidates. */
|
|
2277
|
+
const SERVICES_PATH = `${ROUTE_PREFIX}${SERVICE_DISCOVER_ROUTE}`;
|
|
1025
2278
|
/**
|
|
1026
2279
|
* Resolve the figure one request names, as an answer rather than an exception.
|
|
1027
2280
|
* @param address - the address the request carried, empty when it carried none.
|
|
@@ -1098,16 +2351,106 @@ async function removeAnswer(target) {
|
|
|
1098
2351
|
/**
|
|
1099
2352
|
* Build the route handlers this plugin registers.
|
|
1100
2353
|
* @param deps - Session lookup.
|
|
2354
|
+
* @param liveRuntime - live-page operation, replaceable at the listener boundary in tests.
|
|
1101
2355
|
* @returns one handler per route path.
|
|
1102
2356
|
*/
|
|
1103
|
-
function createHandlers(deps) {
|
|
2357
|
+
function createHandlers(deps, liveRuntime = { open: openLive }) {
|
|
2358
|
+
/**
|
|
2359
|
+
* The page being served to the panel, when one is.
|
|
2360
|
+
*
|
|
2361
|
+
* One at a time, because the panel shows one frame: a second page would leave
|
|
2362
|
+
* the first proxy listening with nothing pointing at it. The state lives here,
|
|
2363
|
+
* per composition, so two hosts in one process never share a frame.
|
|
2364
|
+
*/
|
|
2365
|
+
let live;
|
|
2366
|
+
/** The latest open request admitted, even while its listener is still starting. */
|
|
2367
|
+
let liveOpenSequence = 0;
|
|
2368
|
+
/**
|
|
2369
|
+
* Open the page one request names, or close whatever page is open.
|
|
2370
|
+
* @param req - the request naming the operation.
|
|
2371
|
+
* @param requestUrl - the already parsed request target.
|
|
2372
|
+
* @returns the answer to send.
|
|
2373
|
+
*/
|
|
2374
|
+
const liveAnswer = async (req, requestUrl) => {
|
|
2375
|
+
if (req.method === "POST") {
|
|
2376
|
+
const sequence = liveOpenSequence + 1;
|
|
2377
|
+
liveOpenSequence = sequence;
|
|
2378
|
+
const body = JSON.parse(await readBody(req));
|
|
2379
|
+
const proxy = await liveRuntime.open(new URL(typeof body["url"] === "string" ? body["url"] : ""));
|
|
2380
|
+
if (sequence !== liveOpenSequence) {
|
|
2381
|
+
proxy.close();
|
|
2382
|
+
return {
|
|
2383
|
+
ok: false,
|
|
2384
|
+
status: 409,
|
|
2385
|
+
error: "a newer local page superseded this one"
|
|
2386
|
+
};
|
|
2387
|
+
}
|
|
2388
|
+
live?.close();
|
|
2389
|
+
live = proxy;
|
|
2390
|
+
return {
|
|
2391
|
+
ok: true,
|
|
2392
|
+
body: {
|
|
2393
|
+
ok: true,
|
|
2394
|
+
url: proxy.url
|
|
2395
|
+
}
|
|
2396
|
+
};
|
|
2397
|
+
}
|
|
2398
|
+
if (req.method === "DELETE") {
|
|
2399
|
+
const expected = requestUrl.searchParams.get("url");
|
|
2400
|
+
if (expected === null) liveOpenSequence += 1;
|
|
2401
|
+
if (live !== void 0 && (expected === null || expected === live.url)) {
|
|
2402
|
+
live.close();
|
|
2403
|
+
live = void 0;
|
|
2404
|
+
}
|
|
2405
|
+
return {
|
|
2406
|
+
ok: true,
|
|
2407
|
+
body: { ok: true }
|
|
2408
|
+
};
|
|
2409
|
+
}
|
|
2410
|
+
return {
|
|
2411
|
+
ok: false,
|
|
2412
|
+
status: 405,
|
|
2413
|
+
error: `method ${String(req.method)} is not allowed`
|
|
2414
|
+
};
|
|
2415
|
+
};
|
|
2416
|
+
/**
|
|
2417
|
+
* Scan the bounded local candidates the browser asked about.
|
|
2418
|
+
* @param req - a POST carrying a session and optional recent URLs.
|
|
2419
|
+
* @returns reachable services, or a method refusal.
|
|
2420
|
+
*/
|
|
2421
|
+
const serviceAnswer = async (req) => {
|
|
2422
|
+
if (req.method !== "POST") return {
|
|
2423
|
+
ok: false,
|
|
2424
|
+
status: 405,
|
|
2425
|
+
error: `method ${String(req.method)} is not allowed`
|
|
2426
|
+
};
|
|
2427
|
+
const raw = JSON.parse(await readBody(req));
|
|
2428
|
+
const sessionId = typeof raw["sessionId"] === "string" ? raw["sessionId"] : "";
|
|
2429
|
+
if (sessionId === "") return {
|
|
2430
|
+
ok: false,
|
|
2431
|
+
status: 400,
|
|
2432
|
+
error: "service discovery needs a session"
|
|
2433
|
+
};
|
|
2434
|
+
const recent = Array.isArray(raw["recent"]) ? raw["recent"].filter((value) => typeof value === "string").slice(0, 16) : void 0;
|
|
2435
|
+
return {
|
|
2436
|
+
ok: true,
|
|
2437
|
+
body: {
|
|
2438
|
+
ok: true,
|
|
2439
|
+
services: await discoverServices({
|
|
2440
|
+
sessionId,
|
|
2441
|
+
...recent === void 0 ? {} : { recent }
|
|
2442
|
+
}, deps.sessionCwd)
|
|
2443
|
+
}
|
|
2444
|
+
};
|
|
2445
|
+
};
|
|
1104
2446
|
const annotation = async (req, res) => {
|
|
1105
2447
|
if (!guarded(req)) {
|
|
1106
2448
|
sendFailure(res, 403, "missing plugin guard header");
|
|
1107
2449
|
return;
|
|
1108
2450
|
}
|
|
1109
2451
|
try {
|
|
1110
|
-
const
|
|
2452
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
2453
|
+
const answer = url.pathname === LIVE_PATH ? await liveAnswer(req, url) : url.pathname === SERVICES_PATH ? await serviceAnswer(req) : await route(req, url, deps);
|
|
1111
2454
|
if (answer.ok) {
|
|
1112
2455
|
sendJson(res, 200, answer.body);
|
|
1113
2456
|
return;
|
|
@@ -1117,7 +2460,14 @@ function createHandlers(deps) {
|
|
|
1117
2460
|
sendFailure(res, 400, error instanceof Error ? error.message : String(error));
|
|
1118
2461
|
}
|
|
1119
2462
|
};
|
|
1120
|
-
return {
|
|
2463
|
+
return {
|
|
2464
|
+
annotation,
|
|
2465
|
+
close() {
|
|
2466
|
+
liveOpenSequence += 1;
|
|
2467
|
+
live?.close();
|
|
2468
|
+
live = void 0;
|
|
2469
|
+
}
|
|
2470
|
+
};
|
|
1121
2471
|
}
|
|
1122
2472
|
/**
|
|
1123
2473
|
* Dispatch one guarded request to the operation its method names.
|
|
@@ -1226,23 +2576,23 @@ function apply(ctx) {
|
|
|
1226
2576
|
workspaceRegistry: ctx.get("workspaceRegistry")
|
|
1227
2577
|
});
|
|
1228
2578
|
const handlers = createHandlers({ sessionCwd: (sessionId) => resolveSessionCwd(sessionId, sources()) });
|
|
1229
|
-
ctx.effect(() =>
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
2579
|
+
ctx.effect(() => {
|
|
2580
|
+
const unregister = webServer.register({
|
|
2581
|
+
kind: "prefix",
|
|
2582
|
+
path: ROUTE_PREFIX,
|
|
2583
|
+
handler: handlers.annotation
|
|
2584
|
+
});
|
|
2585
|
+
return () => {
|
|
2586
|
+
unregister();
|
|
2587
|
+
handlers.close();
|
|
2588
|
+
};
|
|
2589
|
+
}, "dsh-figmark: annotation route");
|
|
1234
2590
|
ctx.effect(() => systemPrompt.section({
|
|
1235
2591
|
name: GUIDANCE_NAME,
|
|
1236
2592
|
order: GUIDANCE_ORDER,
|
|
1237
2593
|
text: GUIDANCE,
|
|
1238
2594
|
stable: true
|
|
1239
2595
|
}), "dsh-figmark: guidance");
|
|
1240
|
-
ctx.effect(() => systemPrompt.section({
|
|
1241
|
-
name: EDIT_GUIDANCE_NAME,
|
|
1242
|
-
order: EDIT_GUIDANCE_ORDER,
|
|
1243
|
-
text: EDIT_GUIDANCE,
|
|
1244
|
-
stable: true
|
|
1245
|
-
}), "dsh-figmark: edit guidance");
|
|
1246
2596
|
}
|
|
1247
2597
|
//#endregion
|
|
1248
2598
|
export { apply, inject, name };
|