retake-dev 0.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +91 -0
- package/README.md +198 -0
- package/bin/retake.js +319 -0
- package/package.json +87 -0
- package/src/code-versions.js +357 -0
- package/src/core.js +265 -0
- package/src/plugin.js +137 -0
- package/src/runtime/00-core.js +308 -0
- package/src/runtime/10-animations.js +362 -0
- package/src/runtime/20-media.js +157 -0
- package/src/runtime/30-recorder.js +315 -0
- package/src/runtime/32-state.js +315 -0
- package/src/runtime/35-network.js +789 -0
- package/src/runtime/36-scripts.js +129 -0
- package/src/runtime/37-next.js +150 -0
- package/src/runtime/38-observers.js +252 -0
- package/src/runtime/40-input.js +795 -0
- package/src/runtime/45-hover.js +78 -0
- package/src/runtime/50-engine.js +341 -0
- package/src/runtime/60-preview.js +437 -0
- package/src/runtime/65-timeline.js +289 -0
- package/src/runtime/66-activity.js +114 -0
- package/src/runtime/67-csssource.js +226 -0
- package/src/runtime/70-boot.js +460 -0
- package/src/server/api.js +285 -0
- package/src/server/child.js +174 -0
- package/src/server/detect.js +167 -0
- package/src/server/front.js +647 -0
- package/src/server/mcp.js +332 -0
- package/src/shell/00-state.js +85 -0
- package/src/shell/05-api.js +136 -0
- package/src/shell/10-dock.js +754 -0
- package/src/shell/12-checkpoint.js +121 -0
- package/src/shell/15-session.js +291 -0
- package/src/shell/20-timeline.js +734 -0
- package/src/shell/25-input.js +537 -0
- package/src/shell/30-notes.js +870 -0
- package/src/shell/40-code.js +80 -0
- package/src/shell/90-handle.js +15 -0
- package/src/shell/shell.css +295 -0
- package/src/shell/shell.html +59 -0
- package/types/client.d.ts +73 -0
- package/types/index.d.ts +111 -0
|
@@ -0,0 +1,870 @@
|
|
|
1
|
+
// Picking elements in the prototype. Two tools share it:
|
|
2
|
+
// - Comment (or hold ⌘): click an element, down to the innermost child, and
|
|
3
|
+
// write a note. Notes keep their moment and branch, show as numbered pins on
|
|
4
|
+
// the prototype and tags on the timeline, and copy as a prompt.
|
|
5
|
+
// - Select: pick one component; scrubbing then rewinds only that component.
|
|
6
|
+
|
|
7
|
+
let hovered = null
|
|
8
|
+
let lastPointer = null
|
|
9
|
+
let draft = null
|
|
10
|
+
let openNote = null
|
|
11
|
+
// A note is being written (a frame built behind waits to swap in until it's done).
|
|
12
|
+
const composing = () => !!draft
|
|
13
|
+
const hl = $("#wb-hl")
|
|
14
|
+
const scopeBox = $("#wb-scope")
|
|
15
|
+
const card = $("#wb-note")
|
|
16
|
+
// Select and Comment work on the past (the app is view-only there).
|
|
17
|
+
const isStill = () => !!(D.last && D.last.started && !isInteractive())
|
|
18
|
+
const mode = () => (!isStill() ? null : D.metaHeld ? "comment" : D.picking)
|
|
19
|
+
|
|
20
|
+
const shell = window.__retakeShell
|
|
21
|
+
shell.inspecting = false
|
|
22
|
+
// A disabled form control gets no click (pointer events still come): its
|
|
23
|
+
// pointerup picks it instead.
|
|
24
|
+
const noClick = (el) => {
|
|
25
|
+
try {
|
|
26
|
+
return !!(el && el.closest && el.closest(":is(button, input, select, textarea, option, optgroup):disabled"))
|
|
27
|
+
} catch {
|
|
28
|
+
return false
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
shell.inspect = (e) => {
|
|
32
|
+
const el = e.target.nodeType === 1 ? e.target : e.target.parentElement
|
|
33
|
+
if (e.type === "pointermove" || e.type === "pointerover") {
|
|
34
|
+
buildLayers(e.clientX, e.clientY, usable(el))
|
|
35
|
+
} else if (e.type === "wheel") {
|
|
36
|
+
if (Math.abs(e.deltaY) >= 4) cycleLayer(e.deltaY > 0 ? 1 : -1)
|
|
37
|
+
} else if (e.type === "keydown" && e.key === "Tab") {
|
|
38
|
+
cycleLayer(e.shiftKey ? -1 : 1)
|
|
39
|
+
} else if ((e.type === "click" || (e.type === "pointerup" && noClick(el))) && usable(el)) {
|
|
40
|
+
const p = pick()
|
|
41
|
+
if (mode() === "select") setScope(p ? p.el : usable(el))
|
|
42
|
+
else openComposer(p ? p.el : usable(el), { x: e.clientX, y: e.clientY }, p)
|
|
43
|
+
} else if (e.type === "keydown" && e.key === "Escape") {
|
|
44
|
+
setPicking(null)
|
|
45
|
+
setScope(null)
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
// ⌘ is read from every pointer move too, so a missed keyup (a ⌘-shortcut that
|
|
49
|
+
// took focus away, like a screenshot) can't leave the tool stuck on.
|
|
50
|
+
shell.appPointerDown = () => {
|
|
51
|
+
if (!card.hidden && !draft) closeCard()
|
|
52
|
+
}
|
|
53
|
+
shell.pointer = (x, y, meta) => {
|
|
54
|
+
lastPointer = { x, y }
|
|
55
|
+
if (meta !== undefined && meta !== D.metaHeld) shell.meta(meta)
|
|
56
|
+
}
|
|
57
|
+
shell.meta = (down) => {
|
|
58
|
+
if (D.metaHeld === down) return
|
|
59
|
+
D.metaHeld = down
|
|
60
|
+
syncPicking()
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// ---- the layer picker: what's under the pointer, including pseudo-elements ----------
|
|
64
|
+
// ⌘ over the app lists the layers at that point, topmost first: each element,
|
|
65
|
+
// and after it any animation running on its ::before/::after (a shimmer on a
|
|
66
|
+
// skeleton, say). Wheel or Tab moves through them; a click picks the
|
|
67
|
+
// highlighted one.
|
|
68
|
+
|
|
69
|
+
let layers = [] // [{ el, pseudo, anim, name, label }]
|
|
70
|
+
let layerIdx = 0
|
|
71
|
+
let layersKey = ""
|
|
72
|
+
const layersEl = $("#wb-layers")
|
|
73
|
+
const pick = () => layers[layerIdx] || null
|
|
74
|
+
|
|
75
|
+
const shortLabel = (el) => {
|
|
76
|
+
const tag = el.tagName.toLowerCase()
|
|
77
|
+
if (el.id) return `${tag}#${el.id}`
|
|
78
|
+
const c = [...el.classList][0]
|
|
79
|
+
return c ? `${tag}.${c}` : tag
|
|
80
|
+
}
|
|
81
|
+
function animTiming(a) {
|
|
82
|
+
try {
|
|
83
|
+
const t = a.effect.getComputedTiming ? a.effect.getComputedTiming() : a.effect.getTiming()
|
|
84
|
+
const d = Number(t.duration) || 0
|
|
85
|
+
const it = t.iterations
|
|
86
|
+
return `${msWord(d)}${it === Infinity ? " loop" : it > 1 ? ` ×${it}` : ""}`
|
|
87
|
+
} catch {
|
|
88
|
+
return ""
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
const animName = (a) => a.animationName || (a.transitionProperty ? `${a.transitionProperty} transition` : "animation")
|
|
92
|
+
|
|
93
|
+
function buildLayers(x, y, fallback) {
|
|
94
|
+
let els = []
|
|
95
|
+
try {
|
|
96
|
+
els = D.frame.contentDocument.elementsFromPoint(x, y)
|
|
97
|
+
} catch {}
|
|
98
|
+
const seen = new Set()
|
|
99
|
+
const out = []
|
|
100
|
+
for (const raw of els) {
|
|
101
|
+
const el = usable(raw)
|
|
102
|
+
if (!el || seen.has(el)) continue
|
|
103
|
+
seen.add(el)
|
|
104
|
+
if (seen.size > 4) break
|
|
105
|
+
out.push({ el, pseudo: null, anim: null, name: null, label: shortLabel(el) })
|
|
106
|
+
let anims = []
|
|
107
|
+
try {
|
|
108
|
+
anims = el.getAnimations({ subtree: true })
|
|
109
|
+
} catch {}
|
|
110
|
+
const names = new Set()
|
|
111
|
+
for (const a of anims) {
|
|
112
|
+
const pe = a.effect && a.effect.pseudoElement
|
|
113
|
+
if (!pe || a.effect.target !== el) continue
|
|
114
|
+
const name = animName(a)
|
|
115
|
+
if (names.has(pe + name)) continue
|
|
116
|
+
names.add(pe + name)
|
|
117
|
+
out.push({ el, pseudo: pe, anim: a, name, label: `${pe} · ${name} · ${animTiming(a)}` })
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (!out.length && fallback) out.push({ el: fallback, pseudo: null, anim: null, name: null, label: shortLabel(fallback) })
|
|
121
|
+
const key = out.map((l) => l.label).join("|")
|
|
122
|
+
if (key !== layersKey) {
|
|
123
|
+
layersKey = key
|
|
124
|
+
layerIdx = 0
|
|
125
|
+
}
|
|
126
|
+
layers = out
|
|
127
|
+
hovered = pick() ? pick().el : null
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function cycleLayer(dir) {
|
|
131
|
+
if (layers.length < 2) return
|
|
132
|
+
layerIdx = (layerIdx + dir + layers.length) % layers.length
|
|
133
|
+
hovered = pick().el
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// The chip beside the pointer: the top layers, the pick highlighted.
|
|
137
|
+
let layersHtml = ""
|
|
138
|
+
function renderLayers() {
|
|
139
|
+
const show = mode() === "comment" && layers.length > 0 && lastPointer
|
|
140
|
+
layersEl.hidden = !show
|
|
141
|
+
if (!show) return
|
|
142
|
+
const top = layers.slice(0, 4)
|
|
143
|
+
const html = top.map((l, i) => `<div class="layer${i === layerIdx ? " on" : ""}${l.pseudo ? " pseudo" : ""}">${esc(l.label)}</div>`).join("") + (layers.length > 4 ? `<div class="more">+${layers.length - 4}</div>` : "")
|
|
144
|
+
if (html !== layersHtml) {
|
|
145
|
+
layersHtml = html
|
|
146
|
+
layersEl.innerHTML = html
|
|
147
|
+
}
|
|
148
|
+
const f = D.frame.getBoundingClientRect()
|
|
149
|
+
const w = layersEl.offsetWidth
|
|
150
|
+
const h = layersEl.offsetHeight
|
|
151
|
+
layersEl.style.left = clamp(f.left + lastPointer.x + 16, 8, innerWidth - w - 8) + "px"
|
|
152
|
+
layersEl.style.top = clamp(f.top + lastPointer.y + 16, 8, dock.getBoundingClientRect().top - h - 8) + "px"
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// The page itself isn't a thing to comment on.
|
|
156
|
+
// Inside an SVG icon, the thing you mean is the icon's control (a button or
|
|
157
|
+
// link), or at least the whole <svg>, not one of its paths.
|
|
158
|
+
function usable(el) {
|
|
159
|
+
if (!el || el.tagName === "HTML" || el.tagName === "BODY") return null
|
|
160
|
+
if (el instanceof el.ownerDocument.defaultView.SVGElement) {
|
|
161
|
+
const svgRoot = el.ownerSVGElement ? el.closest("svg:not(svg svg)") || el.ownerSVGElement : el
|
|
162
|
+
const control = svgRoot.parentElement && svgRoot.parentElement.closest("button, a, [role=button], [role=link], label, [id]")
|
|
163
|
+
return control && control.tagName !== "BODY" ? control : svgRoot
|
|
164
|
+
}
|
|
165
|
+
return el
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Leaving the Select tool lets go of the selected component.
|
|
169
|
+
function setPicking(m) {
|
|
170
|
+
if (m !== "select") setScope(null)
|
|
171
|
+
D.picking = m
|
|
172
|
+
syncPicking()
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function syncPicking() {
|
|
176
|
+
shell.inspecting = !!mode()
|
|
177
|
+
setToolActive(!!mode())
|
|
178
|
+
hovered = null
|
|
179
|
+
try {
|
|
180
|
+
const doc = D.frame.contentDocument
|
|
181
|
+
doc.documentElement.style.cursor = mode() ? "crosshair" : ""
|
|
182
|
+
if (mode() && lastPointer) hovered = usable(doc.elementFromPoint(lastPointer.x, lastPointer.y))
|
|
183
|
+
} catch {}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Selecting the same component again clears it.
|
|
187
|
+
function setScope(el) {
|
|
188
|
+
D.scopeEl = el && el === D.scopeEl ? null : el
|
|
189
|
+
if (D.PT && D.last && D.last.previewing) D.PT.preview(D.last.previewAt, D.scopeEl)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ---- describing an element ------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
const cssEsc = (v) => (window.CSS && CSS.escape ? CSS.escape(v) : String(v).replace(/[^\w-]/g, (c) => "\\" + c))
|
|
195
|
+
|
|
196
|
+
// A valid selector for the element (ids like React's ":r1:" or "1st" are
|
|
197
|
+
// escaped): an id if there is one, else tag.class chains with :nth-of-type.
|
|
198
|
+
function cssPath(el) {
|
|
199
|
+
const parts = []
|
|
200
|
+
for (let n = el; n && n.nodeType === 1 && n.tagName !== "HTML"; n = n.parentElement) {
|
|
201
|
+
if (n.id) {
|
|
202
|
+
parts.unshift(`#${cssEsc(n.id)}`)
|
|
203
|
+
break
|
|
204
|
+
}
|
|
205
|
+
let part = n.tagName.toLowerCase()
|
|
206
|
+
const cls = [...n.classList].filter((c) => /^[a-z][\w-]*$/i.test(c)).slice(0, 2)
|
|
207
|
+
if (cls.length) part += "." + cls.map(cssEsc).join(".")
|
|
208
|
+
const same = n.parentElement ? [...n.parentElement.children].filter((c) => c.tagName === n.tagName) : []
|
|
209
|
+
if (same.length > 1) part += `:nth-of-type(${same.indexOf(n) + 1})`
|
|
210
|
+
parts.unshift(part)
|
|
211
|
+
if (n.tagName === "BODY") break
|
|
212
|
+
}
|
|
213
|
+
return parts.join(" > ")
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
const fiberOf = (el) => {
|
|
217
|
+
const key = Object.keys(el).find((k) => k.startsWith("__reactFiber$") || k.startsWith("__reactInternalInstance$"))
|
|
218
|
+
return key ? el[key] : null
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
// A component's name, through memo() and forwardRef() wrappers.
|
|
222
|
+
function componentName(type) {
|
|
223
|
+
for (let t = type, i = 0; t && i < 4; i++) {
|
|
224
|
+
if (typeof t === "function") return t.displayName || t.name || null
|
|
225
|
+
if (typeof t !== "object") return null
|
|
226
|
+
if (t.displayName) return t.displayName
|
|
227
|
+
t = t.render || t.type // forwardRef keeps render, memo keeps type
|
|
228
|
+
}
|
|
229
|
+
return null
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Library wrappers that sit between an element and the component that wrote
|
|
233
|
+
// it (framer-motion presence, Radix slots, providers). Not what a note means.
|
|
234
|
+
const INTERNAL = /^(PopChild|PopChildMeasure|PresenceChild|AnimatePresence|MotionComponent|MotionDOMComponent|LayoutGroup|LazyMotion|MotionConfig|Slot|SlotClone|Slottable|Primitive\b.*|Presence|Portal|DismissableLayer|FocusScope|RemoveScroll|Suspense|StrictMode|Fragment|.*Provider|.*Consumer|.*Context)$/
|
|
235
|
+
|
|
236
|
+
// Nearest components, innermost first: the components that rendered the
|
|
237
|
+
// element (React's owner chain), which skips wrappers it was merely passed
|
|
238
|
+
// through; failing that, its ancestors.
|
|
239
|
+
function reactComponents(el) {
|
|
240
|
+
const names = []
|
|
241
|
+
const add = (name) => name && /^[A-Z]/.test(name) && !INTERNAL.test(name) && !names.includes(name) && names.push(name)
|
|
242
|
+
const f0 = fiberOf(el)
|
|
243
|
+
for (let o = f0 && f0._debugOwner, i = 0; o && i < 30 && names.length < 4; o = o._debugOwner || o.owner, i++) add(componentName(o.type) || o.name)
|
|
244
|
+
for (let f = f0; f && names.length < 4; f = f.return) if (!names.length || names.length < 2) add(componentName(f.type))
|
|
245
|
+
return names
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
// Where the element was written: React's debug info. React 18 and older keep
|
|
249
|
+
// _debugSource (already source lines). React 19 keeps a stack (_debugStack)
|
|
250
|
+
// whose first app frame is the JSX; those are lines of the file as served
|
|
251
|
+
// (types stripped, JSX compiled), which mapSource() turns back into source
|
|
252
|
+
// lines with the module's own source map.
|
|
253
|
+
function sourceOf(el) {
|
|
254
|
+
for (let f = fiberOf(el), i = 0; f && i < 12; f = f.return, i++) {
|
|
255
|
+
const src = f._debugSource
|
|
256
|
+
if (src && src.fileName) return { file: shortPath(src.fileName), line: src.lineNumber || null, mapped: true }
|
|
257
|
+
const stack = f._debugStack && (f._debugStack.stack || String(f._debugStack))
|
|
258
|
+
if (stack) {
|
|
259
|
+
for (const line of stack.split("\n").slice(1)) {
|
|
260
|
+
const m = line.match(/(\S+?):(\d+):(\d+)\)?\s*$/)
|
|
261
|
+
if (!m || /node_modules|\/\.vite\/deps\/|react-dom|react\.development|jsx-dev-runtime/.test(m[1])) continue
|
|
262
|
+
const url = m[1].replace(/^.*?\(/, "")
|
|
263
|
+
// A server component's frame (React 19 dev: about://React/Server/file:///…/.next/…chunk.js)
|
|
264
|
+
// is a compiled chunk on the server: nothing the dock can fetch or map (F76).
|
|
265
|
+
if (!/^https?:\/\//.test(url)) continue
|
|
266
|
+
const src = { file: shortPath(url), line: Number(m[2]), col: Number(m[3]), url, mapped: false }
|
|
267
|
+
mapSource(src)
|
|
268
|
+
return src
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
return null
|
|
273
|
+
}
|
|
274
|
+
function shortPath(p) {
|
|
275
|
+
try {
|
|
276
|
+
if (/^https?:/.test(p)) p = new URL(p).pathname
|
|
277
|
+
} catch {}
|
|
278
|
+
return p.replace(/\?.*$/, "").replace(/^\/@fs/, "")
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
// ---- source maps: served line → source line ----------------------------------------
|
|
282
|
+
|
|
283
|
+
const B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
|
|
284
|
+
function decodeMappings(mappings) {
|
|
285
|
+
const lines = []
|
|
286
|
+
let src = 0, oLine = 0, oCol = 0
|
|
287
|
+
for (const text of mappings.split(";")) {
|
|
288
|
+
const segs = []
|
|
289
|
+
let gCol = 0
|
|
290
|
+
for (const seg of text.split(",")) {
|
|
291
|
+
if (!seg) continue
|
|
292
|
+
const v = []
|
|
293
|
+
for (let i = 0, shift = 0, acc = 0; i < seg.length; i++) {
|
|
294
|
+
const d = B64.indexOf(seg[i])
|
|
295
|
+
acc += (d & 31) << shift
|
|
296
|
+
if (d & 32) shift += 5
|
|
297
|
+
else {
|
|
298
|
+
v.push(acc & 1 ? -(acc >> 1) : acc >> 1)
|
|
299
|
+
acc = shift = 0
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
gCol += v[0]
|
|
303
|
+
if (v.length >= 4) {
|
|
304
|
+
src += v[1]
|
|
305
|
+
oLine += v[2]
|
|
306
|
+
oCol += v[3]
|
|
307
|
+
segs.push([gCol, src, oLine, oCol])
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
lines.push(segs)
|
|
311
|
+
}
|
|
312
|
+
return lines
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const sourceMaps = new Map() // module url (without query) → Promise<{ map, lines } | null>
|
|
316
|
+
function loadMap(url) {
|
|
317
|
+
const key = url.replace(/\?.*$/, "")
|
|
318
|
+
if (!sourceMaps.has(key))
|
|
319
|
+
sourceMaps.set(
|
|
320
|
+
key,
|
|
321
|
+
(async () => {
|
|
322
|
+
try {
|
|
323
|
+
if (!/^https?:\/\//.test(url)) return null
|
|
324
|
+
const code = await (await fetch(url)).text()
|
|
325
|
+
const m = code.match(/\/\/[#@] sourceMappingURL=(\S+)\s*$/)
|
|
326
|
+
if (!m) return null
|
|
327
|
+
let json
|
|
328
|
+
if (m[1].startsWith("data:")) {
|
|
329
|
+
const b64 = m[1].slice(m[1].indexOf(",") + 1)
|
|
330
|
+
json = /;base64/.test(m[1].slice(0, m[1].indexOf(","))) ? new TextDecoder().decode(Uint8Array.from(atob(b64), (c) => c.charCodeAt(0))) : decodeURIComponent(b64)
|
|
331
|
+
} else json = await (await fetch(new URL(m[1], url))).text()
|
|
332
|
+
const map = JSON.parse(json)
|
|
333
|
+
return { map, lines: decodeMappings(map.mappings || "") }
|
|
334
|
+
} catch {
|
|
335
|
+
return null
|
|
336
|
+
}
|
|
337
|
+
})(),
|
|
338
|
+
)
|
|
339
|
+
return sourceMaps.get(key)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Rewrites src.line (and file, if the map names another) in place, so the
|
|
343
|
+
// note that holds it is saved with the source line.
|
|
344
|
+
async function mapSource(src) {
|
|
345
|
+
const m = await loadMap(src.url)
|
|
346
|
+
const segs = m && m.lines[src.line - 1]
|
|
347
|
+
if (segs && segs.length) {
|
|
348
|
+
let best = segs[0]
|
|
349
|
+
for (const s of segs) if (s[0] <= src.col - 1) best = s
|
|
350
|
+
src.line = best[2] + 1
|
|
351
|
+
const name = m.map.sources && m.map.sources[best[1]]
|
|
352
|
+
if (name && !name.startsWith("\0") && shortPath(name).split("/").pop() !== src.file.split("/").pop()) src.file = shortPath(name)
|
|
353
|
+
}
|
|
354
|
+
src.mapped = true
|
|
355
|
+
delete src.col
|
|
356
|
+
delete src.url
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
// The computed styles an agent would ask about first. Defaults are left out.
|
|
360
|
+
const KEY_STYLES = ["display", "position", "width", "height", "margin", "padding", "color", "background-color", "font-size", "font-weight", "border-radius", "opacity", "transform", "transition", "animation-name", "z-index", "gap"]
|
|
361
|
+
const BORING = new Set(["none", "normal", "auto", "0px", "static", "visible", "rgba(0, 0, 0, 0)", "all 0s ease 0s", "0s", ""])
|
|
362
|
+
function keyStyles(el) {
|
|
363
|
+
const out = {}
|
|
364
|
+
try {
|
|
365
|
+
const cs = el.ownerDocument.defaultView.getComputedStyle(el)
|
|
366
|
+
for (const p of KEY_STYLES) {
|
|
367
|
+
const v = cs.getPropertyValue(p)
|
|
368
|
+
if (!BORING.has(v) && !(p === "opacity" && v === "1")) out[p] = p === "transition" ? summariseTransition(v) : v
|
|
369
|
+
}
|
|
370
|
+
} catch {}
|
|
371
|
+
return out
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
// Tailwind's transition-colors lists ten properties with one timing; say
|
|
375
|
+
// that once: "10 properties 0.15s cubic-bezier(0.4, 0, 0.2, 1)".
|
|
376
|
+
function summariseTransition(v) {
|
|
377
|
+
const parts = v.split(/,(?![^(]*\))/).map((x) => x.trim())
|
|
378
|
+
if (parts.length <= 3) return v
|
|
379
|
+
const groups = new Map()
|
|
380
|
+
for (const part of parts) {
|
|
381
|
+
const [prop, ...rest] = part.split(/\s+(?![^(]*\))/)
|
|
382
|
+
const timing = rest.join(" ")
|
|
383
|
+
groups.set(timing, [...(groups.get(timing) || []), prop])
|
|
384
|
+
}
|
|
385
|
+
return [...groups].map(([timing, props]) => (props.length > 2 ? `${props.length} properties ${timing}` : `${props.join(", ")} ${timing}`)).join("; ")
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function describe(el) {
|
|
389
|
+
const tag = el.tagName.toLowerCase()
|
|
390
|
+
// Tag plus its id or first class: short enough to read whole.
|
|
391
|
+
const id = el.id ? `#${el.id}` : ""
|
|
392
|
+
const cls = id ? "" : [...el.classList].slice(0, 1).map((c) => `.${c}`).join("")
|
|
393
|
+
const text = (el.innerText || el.textContent || "").trim().replace(/\s+/g, " ").slice(0, 80)
|
|
394
|
+
const r = el.getBoundingClientRect()
|
|
395
|
+
let page = "/"
|
|
396
|
+
try {
|
|
397
|
+
const u = new URL(D.frame.contentWindow.location.href)
|
|
398
|
+
u.searchParams.delete("__wb")
|
|
399
|
+
page = u.pathname + u.search
|
|
400
|
+
} catch {}
|
|
401
|
+
return {
|
|
402
|
+
label: `<${tag}${id}${cls}>`,
|
|
403
|
+
text,
|
|
404
|
+
selector: cssPath(el),
|
|
405
|
+
components: reactComponents(el),
|
|
406
|
+
source: sourceOf(el),
|
|
407
|
+
classes: [...el.classList],
|
|
408
|
+
styles: keyStyles(el),
|
|
409
|
+
rect: { x: Math.round(r.x), y: Math.round(r.y), w: Math.round(r.width), h: Math.round(r.height) },
|
|
410
|
+
page,
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// What was moving on the element at t: on it or inside it, else on one of its
|
|
415
|
+
// nearest ancestors (a label inside a sliding button). Never a clip from
|
|
416
|
+
// elsewhere on the page: a note on a still element says nothing is animating.
|
|
417
|
+
const CLIP_ANCESTORS = 4
|
|
418
|
+
function clipFor(t, selector, el) {
|
|
419
|
+
let hit = clipAt(t, selector)
|
|
420
|
+
for (let n = el && el.parentElement, i = 0; !hit && n && n.tagName !== "BODY" && i < CLIP_ANCESTORS; n = n.parentElement, i++) hit = clipAt(t, cssPath(n))
|
|
421
|
+
if (!hit || !hit.clip) return null
|
|
422
|
+
const c = hit.clip
|
|
423
|
+
const end = c.end == null ? null : c.end
|
|
424
|
+
return { id: c.id, offset: Math.round(hit.offset), duration: end == null ? null : Math.round(end - c.start), label: c.label || c.property || c.kind, kind: c.kind, property: c.property || null, selector: c.selector || null }
|
|
425
|
+
}
|
|
426
|
+
const clipPhrase = (c) =>
|
|
427
|
+
`${c.offset}ms into ${c.duration != null ? `a ${c.duration}ms` : "a running"} ${c.property ? `${c.property} ` : ""}${c.kind === "transition" ? "transition" : c.kind === "css-animation" ? `animation (${c.label})` : c.kind === "waapi" ? "animation" : c.label || "animation"}${c.selector ? ` on ${c.selector}` : ""}`
|
|
428
|
+
|
|
429
|
+
// The note as a prompt for a coding agent: what to change, where it is in
|
|
430
|
+
// the code, and the moment it's about.
|
|
431
|
+
function prompt(n) {
|
|
432
|
+
const b = branchById(n.branchId)
|
|
433
|
+
const start = D.last ? D.last.start : 0
|
|
434
|
+
const parent = b && branchById(b.parentId)
|
|
435
|
+
const el = n.el
|
|
436
|
+
const lines = [`## ${n.text}`, "", `Page: ${el.page}`, `Element: ${el.label}${el.text ? ` "${el.text}"` : ""}`, `Selector: ${el.selector}`]
|
|
437
|
+
if (el.components.length) lines.push(`Component: ${el.components.join(" < ")}`)
|
|
438
|
+
if (el.pseudo) lines.push(`Animation: ${pseudoSentence(el)}`)
|
|
439
|
+
if (el.cssSource) lines.push(`CSS: ${el.cssSource.file}${el.cssSource.line ? `:${el.cssSource.line}` : ""}${el.cssSource.keyframes && el.cssSource.keyframes.line ? ` (@keyframes ${el.cssSource.keyframes.name} at ${el.cssSource.keyframes.file}:${el.cssSource.keyframes.line})` : ""}`)
|
|
440
|
+
if (el.source) lines.push(`Source: ${el.source.file}${el.source.line ? `:${el.source.line}` : ""}`)
|
|
441
|
+
if (el.classes && el.classes.length) lines.push(`Classes: ${el.classes.join(" ")}`)
|
|
442
|
+
const st = el.styles || {}
|
|
443
|
+
if (Object.keys(st).length) lines.push(`Computed: ${Object.entries(st).map(([k, v]) => `${k}: ${v}`).join("; ")}`)
|
|
444
|
+
lines.push(`Size: ${el.rect.w}×${el.rect.h} at (${el.rect.x}, ${el.rect.y})`)
|
|
445
|
+
lines.push(`Moment: ${fmt(n.t - start)} into the recording${n.clip ? `, ${clipPhrase(n.clip)}` : ", nothing animating"}`)
|
|
446
|
+
lines.push(`Timeline: "${b ? b.name : "Timeline"}"${parent ? `, branched from "${parent.name}" at ${fmt(b.forkAt - start)}` : ""}`)
|
|
447
|
+
const agent = (n.replies || []).filter((r) => r.from === "agent")
|
|
448
|
+
if (agent.length) lines.push("", "Earlier replies:", ...agent.map((r) => `- ${r.text}`))
|
|
449
|
+
return lines.join("\n")
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
async function copy(text, btn) {
|
|
453
|
+
try {
|
|
454
|
+
await navigator.clipboard.writeText(text)
|
|
455
|
+
} catch {
|
|
456
|
+
const ta = document.createElement("textarea")
|
|
457
|
+
ta.value = text
|
|
458
|
+
document.body.appendChild(ta)
|
|
459
|
+
ta.select()
|
|
460
|
+
document.execCommand("copy")
|
|
461
|
+
ta.remove()
|
|
462
|
+
}
|
|
463
|
+
if (btn) {
|
|
464
|
+
const was = btn.textContent
|
|
465
|
+
btn.textContent = "Copied"
|
|
466
|
+
setTimeout(() => (btn.textContent = was), 1200)
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---- notes as the server keeps them (CONTRACT.md "Note") --------------------------
|
|
471
|
+
|
|
472
|
+
const newNoteId = () => "n" + Date.now().toString(36) + Math.random().toString(36).slice(2, 6)
|
|
473
|
+
|
|
474
|
+
function noteForServer(n) {
|
|
475
|
+
return {
|
|
476
|
+
id: n.id,
|
|
477
|
+
branchId: n.branchId,
|
|
478
|
+
t: n.t,
|
|
479
|
+
clip: n.clip || null,
|
|
480
|
+
selector: n.el.selector,
|
|
481
|
+
component: n.el.components[0] || null,
|
|
482
|
+
source: n.el.source || null,
|
|
483
|
+
classes: n.el.classes || [],
|
|
484
|
+
rect: n.el.rect,
|
|
485
|
+
text: n.text,
|
|
486
|
+
status: n.status || "pending",
|
|
487
|
+
replies: n.replies || [],
|
|
488
|
+
el: n.el,
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
function noteFromServer(n) {
|
|
493
|
+
const el = n.el || {
|
|
494
|
+
label: n.selector || "element",
|
|
495
|
+
text: "",
|
|
496
|
+
selector: n.selector || "",
|
|
497
|
+
components: n.component ? [n.component] : [],
|
|
498
|
+
rect: n.rect || { x: 0, y: 0, w: 0, h: 0 },
|
|
499
|
+
page: "/",
|
|
500
|
+
classes: n.classes || [],
|
|
501
|
+
source: n.source || null,
|
|
502
|
+
}
|
|
503
|
+
return { id: n.id, branchId: n.branchId, t: n.t, text: n.text || "", el, clip: n.clip || null, status: n.status || "pending", replies: n.replies || [] }
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
// A change from outside (an agent replied, or marked it resolved).
|
|
507
|
+
function mergeNote(n) {
|
|
508
|
+
const mine = D.notes.find((x) => String(x.id) === String(n.id))
|
|
509
|
+
if (!mine) {
|
|
510
|
+
if (branchById(n.branchId)) D.notes.push(noteFromServer(n))
|
|
511
|
+
return
|
|
512
|
+
}
|
|
513
|
+
if (n.status) mine.status = n.status
|
|
514
|
+
if (Array.isArray(n.replies)) mine.replies = n.replies
|
|
515
|
+
else if (n.reply) mine.replies = [...(mine.replies || []), typeof n.reply === "string" ? { from: "agent", text: n.reply, at: Date.now() } : n.reply]
|
|
516
|
+
if (openNote === mine) showNote(mine)
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// ---- the note card -----------------------------------------------------------------
|
|
520
|
+
|
|
521
|
+
// Where a note points, on screen: the spot that was clicked, kept relative to
|
|
522
|
+
// its element (so it follows the element if that moved), else to where the
|
|
523
|
+
// element was.
|
|
524
|
+
function anchorOf(el) {
|
|
525
|
+
const f = D.frame.getBoundingClientRect()
|
|
526
|
+
let r = null
|
|
527
|
+
try {
|
|
528
|
+
const node = el.selector && D.frame.contentDocument.querySelector(el.selector)
|
|
529
|
+
if (node) r = node.getBoundingClientRect()
|
|
530
|
+
} catch {}
|
|
531
|
+
if (!r || (!r.width && !r.height)) r = { x: el.rect.x, y: el.rect.y, width: el.rect.w, height: el.rect.h }
|
|
532
|
+
const at = el.at || { dx: 0.5, dy: 0.5 }
|
|
533
|
+
return { x: f.left + r.x + at.dx * r.width, y: f.top + r.y + at.dy * r.height }
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// Next to its spot, with the arrow on it: below if it fits, else above,
|
|
537
|
+
// right, left; always kept on screen and clear of the dock.
|
|
538
|
+
function placeCard(point) {
|
|
539
|
+
card.insertAdjacentHTML("afterbegin", '<i class="arrow"></i>')
|
|
540
|
+
const arrow = card.querySelector(".arrow")
|
|
541
|
+
const w = card.offsetWidth
|
|
542
|
+
const h = card.offsetHeight
|
|
543
|
+
const M = 8
|
|
544
|
+
const GAP = 14
|
|
545
|
+
const bottom = dock.getBoundingClientRect().top - M
|
|
546
|
+
const right = innerWidth - M
|
|
547
|
+
const cx = clamp(point.x - w / 2, M, right - w)
|
|
548
|
+
const cy = clamp(point.y - 32, M, bottom - h)
|
|
549
|
+
const sides = [
|
|
550
|
+
{ side: "below", left: cx, top: point.y + GAP, fits: (p) => p.top + h <= bottom },
|
|
551
|
+
{ side: "above", left: cx, top: point.y - GAP - h, fits: (p) => p.top >= M },
|
|
552
|
+
{ side: "right", left: point.x + GAP, top: cy, fits: (p) => p.left + w <= right },
|
|
553
|
+
{ side: "left", left: point.x - GAP - w, top: cy, fits: (p) => p.left >= M },
|
|
554
|
+
]
|
|
555
|
+
const pick = sides.find((p) => p.fits(p)) || { side: "below", left: cx, top: clamp(point.y + GAP, M, bottom - h) }
|
|
556
|
+
card.dataset.side = pick.side
|
|
557
|
+
card.style.left = pick.left + "px"
|
|
558
|
+
card.style.top = pick.top + "px"
|
|
559
|
+
if (pick.side === "below" || pick.side === "above") {
|
|
560
|
+
arrow.style.left = clamp(point.x - pick.left - 6, 12, w - 24) + "px"
|
|
561
|
+
arrow.style.top = ""
|
|
562
|
+
} else {
|
|
563
|
+
arrow.style.top = clamp(point.y - pick.top - 6, 12, h - 24) + "px"
|
|
564
|
+
arrow.style.left = ""
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function pseudoInfo(layer) {
|
|
569
|
+
const a = layer.anim
|
|
570
|
+
let timing = {}
|
|
571
|
+
try {
|
|
572
|
+
timing = a.effect.getTiming()
|
|
573
|
+
} catch {}
|
|
574
|
+
return {
|
|
575
|
+
pseudoElement: layer.pseudo,
|
|
576
|
+
animationName: layer.name,
|
|
577
|
+
keyframes: a.animationName || null,
|
|
578
|
+
duration: Number(timing.duration) || 0,
|
|
579
|
+
iterations: timing.iterations === Infinity ? "infinite" : timing.iterations || 1,
|
|
580
|
+
delay: Number(timing.delay) || 0,
|
|
581
|
+
}
|
|
582
|
+
}
|
|
583
|
+
// The clip of that pseudo-element animation at t (the runtime keys it by the
|
|
584
|
+
// host's selector plus the pseudo-element).
|
|
585
|
+
function pseudoClip(t, desc) {
|
|
586
|
+
const want = (c) => (c.pseudoElement || null) === desc.pseudo.pseudoElement
|
|
587
|
+
const clips = timeline().clips.filter((c) => want(c) && c.start <= t && (c.end == null || c.end >= t))
|
|
588
|
+
const c = clips.find((c) => c.selector === desc.selector + desc.pseudo.pseudoElement) || clips[0]
|
|
589
|
+
if (!c) return null
|
|
590
|
+
return { id: c.id, offset: Math.round(t - c.start), duration: c.end == null ? null : Math.round(c.end - c.start), label: c.label || desc.pseudo.animationName, kind: c.kind, property: c.property || null, selector: c.selector || null }
|
|
591
|
+
}
|
|
592
|
+
// Where the animation is written in the CSS (the runtime reads Vite's
|
|
593
|
+
// source maps); filled in when it answers.
|
|
594
|
+
function lookUpCss(desc, anim) {
|
|
595
|
+
const pt = D.PT
|
|
596
|
+
if (!pt || typeof pt.cssSourceFor !== "function") return
|
|
597
|
+
Promise.resolve()
|
|
598
|
+
.then(() => pt.cssSourceFor(anim))
|
|
599
|
+
.then((r) => {
|
|
600
|
+
if (!r || (!r.file && !r.line)) return
|
|
601
|
+
// No file: it's in a <style> in the page itself.
|
|
602
|
+
const where = (f) => (f ? shortPath(f) : `${desc.page || "/"} <style>`)
|
|
603
|
+
desc.cssSource = { file: where(r.file), line: r.line || null, keyframes: r.keyframes ? { name: r.keyframes.name, file: where(r.keyframes.file || r.file), line: r.keyframes.line || null } : null }
|
|
604
|
+
})
|
|
605
|
+
.catch(() => {})
|
|
606
|
+
}
|
|
607
|
+
// "The ::after shimmer animation (keyframes `shimmer`, 1.2s, infinite) on .card"
|
|
608
|
+
function pseudoSentence(el) {
|
|
609
|
+
const p = el.pseudo
|
|
610
|
+
const host = el.classes && el.classes.length ? `.${el.classes[0]}` : el.selector
|
|
611
|
+
const bits = [p.keyframes ? `keyframes \`${p.keyframes}\`` : null, p.duration ? msWord(p.duration) : null, p.iterations === "infinite" ? "infinite" : p.iterations > 1 ? `${p.iterations} times` : null].filter(Boolean)
|
|
612
|
+
return `The ${p.pseudoElement} ${p.animationName} animation${bits.length ? ` (${bits.join(", ")})` : ""} on ${host}`
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
// The card's header: the timeline's colour, the moment, the element.
|
|
616
|
+
function meta(t, el, branchId = D.activeId) {
|
|
617
|
+
const start = D.last ? D.last.start : 0
|
|
618
|
+
const b = branchById(branchId)
|
|
619
|
+
return `<div class="note-meta"><span class="dot" style="background:${colorOf(b)}" title="${esc(b ? b.name : "")}"></span><span class="time">${fmt(t - start)}</span><span class="el" title="${esc(el.selector)}">${esc(el.label)}</span></div>`
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
// "140ms into the opacity transition", "2.3s into fm-note-rise (opacity, transform)".
|
|
623
|
+
function shortClip(c) {
|
|
624
|
+
const at = msWord(Math.max(0, c.offset || 0))
|
|
625
|
+
if (c.kind === "transition") return `${at} into the ${c.property ? c.property + " " : ""}transition`
|
|
626
|
+
const name = c.label || "an animation"
|
|
627
|
+
return `${at} into ${name}${c.property && c.property !== name ? ` (${c.property})` : ""}`
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Everything else about the element, folded away.
|
|
631
|
+
function details(el) {
|
|
632
|
+
const rows = [["Selector", el.selector]]
|
|
633
|
+
if (el.components && el.components.length) rows.push(["Component", el.components.join(" < ")])
|
|
634
|
+
if (el.source) rows.push(["Source", `${el.source.file}${el.source.line ? ":" + el.source.line : ""}`])
|
|
635
|
+
if (el.pseudo) rows.push(["Animation", pseudoSentence(el)])
|
|
636
|
+
if (el.cssSource) rows.push(["CSS", `${el.cssSource.file}${el.cssSource.line ? ":" + el.cssSource.line : ""}`])
|
|
637
|
+
if (el.classes && el.classes.length) rows.push(["Classes", el.classes.join(" ")])
|
|
638
|
+
return `<details class="details"><summary>Details</summary><dl>${rows.map(([k, v]) => `<dt>${k}</dt><dd>${esc(v)}</dd>`).join("")}</dl></details>`
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function openComposer(el, point, layer) {
|
|
642
|
+
const s = D.last
|
|
643
|
+
if (!D.PT || !s || !isStill()) return
|
|
644
|
+
const t = s.previewing ? s.previewAt : s.now
|
|
645
|
+
const desc = describe(el)
|
|
646
|
+
// The spot that was clicked, as a fraction of the element.
|
|
647
|
+
const r = el.getBoundingClientRect()
|
|
648
|
+
desc.at = point && r.width && r.height ? { dx: clamp((point.x - r.x) / r.width, 0, 1), dy: clamp((point.y - r.y) / r.height, 0, 1) } : { dx: 0.5, dy: 0.5 }
|
|
649
|
+
// A picked ::before/::after animation: the note is about that.
|
|
650
|
+
if (layer && layer.pseudo) {
|
|
651
|
+
desc.pseudo = pseudoInfo(layer)
|
|
652
|
+
desc.label = `<${shortLabel(el)}>${layer.pseudo}`
|
|
653
|
+
lookUpCss(desc, layer.anim)
|
|
654
|
+
}
|
|
655
|
+
const clip = desc.pseudo ? pseudoClip(t, desc) : clipFor(t, desc.selector, el)
|
|
656
|
+
draft = { el: desc, t, clip }
|
|
657
|
+
openNote = null
|
|
658
|
+
card.innerHTML = `${meta(t, draft.el)}<textarea rows="3" placeholder="What should change here?"></textarea>
|
|
659
|
+
<div class="note-actions"><button data-note-a="cancel">Cancel</button><button data-note-a="save" class="primary">Add note</button></div>`
|
|
660
|
+
card.hidden = false
|
|
661
|
+
placeCard(anchorOf(draft.el))
|
|
662
|
+
const ta = card.querySelector("textarea")
|
|
663
|
+
setTimeout(() => ta.focus())
|
|
664
|
+
// Enter saves and folds the note down to its pin; Shift+Enter is a new line.
|
|
665
|
+
ta.addEventListener("keydown", (e) => {
|
|
666
|
+
if (e.key === "Enter" && !e.shiftKey) {
|
|
667
|
+
e.preventDefault()
|
|
668
|
+
saveDraft()
|
|
669
|
+
}
|
|
670
|
+
if (e.key === "Escape") closeCard()
|
|
671
|
+
})
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function saveDraft() {
|
|
675
|
+
const text = card.querySelector("textarea").value.trim()
|
|
676
|
+
if (draft && text) D.notes.push({ id: newNoteId(), t: draft.t, branchId: D.activeId, text, el: draft.el, clip: draft.clip, status: "pending", replies: [] })
|
|
677
|
+
closeCard()
|
|
678
|
+
// Back to the Hand so the next click is on the prototype, not another note.
|
|
679
|
+
setPicking(null)
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
function showNote(n) {
|
|
683
|
+
// Re-showing the same note (a reply came in) keeps Details as it was.
|
|
684
|
+
const keepOpen = openNote === n && !!card.querySelector(".details[open]")
|
|
685
|
+
openNote = n
|
|
686
|
+
draft = null
|
|
687
|
+
const replies = (n.replies || [])
|
|
688
|
+
.map((r) => `<div class="reply ${r.from === "agent" ? "agent" : "user"}"><span class="who">${r.from === "agent" ? "Agent" : "You"}</span>${esc(r.text)}</div>`)
|
|
689
|
+
.join("")
|
|
690
|
+
const status = n.status || "pending"
|
|
691
|
+
card.innerHTML = `${meta(n.t, n.el, n.branchId)}<p class="note-text">${esc(n.text)}</p>
|
|
692
|
+
${n.clip ? `<div class="note-clip">${esc(shortClip(n.clip))}</div>` : ""}
|
|
693
|
+
${details(n.el)}
|
|
694
|
+
${replies ? `<div class="replies">${replies}</div>` : ""}
|
|
695
|
+
<div class="note-actions"><span class="status s-${esc(status)}">${esc(status[0].toUpperCase() + status.slice(1))}</span>
|
|
696
|
+
<button data-note-a="delete" class="quiet">Delete</button>
|
|
697
|
+
<button data-note-a="resolve">${status === "resolved" ? "Reopen" : "Resolve"}</button>
|
|
698
|
+
<button data-note-a="copy" class="primary">Copy for agent</button></div>`
|
|
699
|
+
if (keepOpen) card.querySelector(".details").open = true
|
|
700
|
+
card.hidden = false
|
|
701
|
+
placeCard(anchorOf(n.el))
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
// Tell the server about a status change (the session save carries it too).
|
|
705
|
+
function patchNote(n, body) {
|
|
706
|
+
if (!net.on) return
|
|
707
|
+
api("PATCH", `notes/${encodeURIComponent(n.id)}`, body).catch(() => {})
|
|
708
|
+
}
|
|
709
|
+
|
|
710
|
+
function closeCard() {
|
|
711
|
+
card.hidden = true
|
|
712
|
+
draft = null
|
|
713
|
+
openNote = null
|
|
714
|
+
}
|
|
715
|
+
|
|
716
|
+
// Returns true when the click was one of ours.
|
|
717
|
+
function handleNoteClick(b) {
|
|
718
|
+
const act = b.dataset.noteA
|
|
719
|
+
if (act === "save") saveDraft()
|
|
720
|
+
if (act === "cancel") closeCard()
|
|
721
|
+
if (act === "copy" && openNote) {
|
|
722
|
+
copy(prompt(openNote), b)
|
|
723
|
+
// Copied: fold the note back to its pin.
|
|
724
|
+
setTimeout(closeCard, 700)
|
|
725
|
+
}
|
|
726
|
+
if (act === "resolve" && openNote) {
|
|
727
|
+
openNote.status = openNote.status === "resolved" ? "pending" : "resolved"
|
|
728
|
+
patchNote(openNote, { status: openNote.status })
|
|
729
|
+
showNote(openNote)
|
|
730
|
+
}
|
|
731
|
+
if (act === "delete" && openNote) {
|
|
732
|
+
D.notes = D.notes.filter((n) => n !== openNote)
|
|
733
|
+
closeCard()
|
|
734
|
+
}
|
|
735
|
+
if (b.dataset.note) goToNoteId(b.dataset.note)
|
|
736
|
+
return !!(act || b.dataset.note)
|
|
737
|
+
}
|
|
738
|
+
|
|
739
|
+
// From a pin, the timeline or the list: go to the note's moment and open it.
|
|
740
|
+
function goToNoteId(id) {
|
|
741
|
+
const n = D.notes.find((x) => String(x.id) === String(id))
|
|
742
|
+
if (!n) return
|
|
743
|
+
if (n.branchId !== D.activeId) switchTo(n.branchId, n.t)
|
|
744
|
+
else if (D.PT && D.last && Math.abs(shownTime(D.last) - n.t) > 5) goTo(n.t)
|
|
745
|
+
showNote(n)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
// ---- the lens: ⌘ held over an element shows its own animations on the lane ----------
|
|
749
|
+
|
|
750
|
+
// The runtime's clipsFor(element) when it has it; else the clips whose
|
|
751
|
+
// element is this one or inside it.
|
|
752
|
+
function clipsOfElement(el) {
|
|
753
|
+
const pt = D.PT
|
|
754
|
+
if (pt && typeof pt.clipsFor === "function") {
|
|
755
|
+
try {
|
|
756
|
+
return pt.clipsFor(el) || []
|
|
757
|
+
} catch {}
|
|
758
|
+
}
|
|
759
|
+
const out = []
|
|
760
|
+
const doc = el.ownerDocument
|
|
761
|
+
for (const c of timeline().clips) {
|
|
762
|
+
if (!c.selector) continue
|
|
763
|
+
let node = null
|
|
764
|
+
try {
|
|
765
|
+
node = doc.querySelector(c.selector)
|
|
766
|
+
} catch {}
|
|
767
|
+
if (node && (node === el || el.contains(node))) out.push(c)
|
|
768
|
+
}
|
|
769
|
+
return out
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
function updateLens() {
|
|
773
|
+
const p = pick()
|
|
774
|
+
const target = D.metaHeld && mode() === "comment" && hovered && hovered.isConnected ? hovered : null
|
|
775
|
+
if (!target) {
|
|
776
|
+
D.lens = null
|
|
777
|
+
return
|
|
778
|
+
}
|
|
779
|
+
const want = `${cssPath(target)}${p && p.el === target && p.pseudo ? p.pseudo + p.name : ""}`
|
|
780
|
+
if (D.lens && D.lens.want === want) return
|
|
781
|
+
let clips = clipsOfElement(target)
|
|
782
|
+
// A picked ::after shimmer: only its own activity.
|
|
783
|
+
if (p && p.el === target && p.pseudo) clips = clips.filter((c) => (c.pseudoElement || null) === p.pseudo && (!p.name || !c.label || c.label === p.name || c.label.startsWith(p.name)))
|
|
784
|
+
D.lens = { el: target, clips, want, key: `${want}:${clips.length}` }
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
// ---- drawing on the prototype --------------------------------------------------------
|
|
788
|
+
|
|
789
|
+
const pinEls = new Map()
|
|
790
|
+
const boxAt = (el, box) => {
|
|
791
|
+
const f = D.frame.getBoundingClientRect()
|
|
792
|
+
const r = el.getBoundingClientRect()
|
|
793
|
+
Object.assign(box.style, { display: "block", left: f.left + r.left + "px", top: f.top + r.top + "px", width: r.width + "px", height: r.height + "px" })
|
|
794
|
+
}
|
|
795
|
+
|
|
796
|
+
function renderExtras(s) {
|
|
797
|
+
// Leaving a still moment (recording) drops back to the Hand.
|
|
798
|
+
if (D.picking && !isStill()) setPicking(null)
|
|
799
|
+
for (const t of /** @type {NodeListOf<HTMLButtonElement>} */ (document.querySelectorAll('[data-tool="select"], [data-tool="comment"]'))) {
|
|
800
|
+
t.disabled = !isStill()
|
|
801
|
+
t.classList.toggle("disabled", !isStill())
|
|
802
|
+
}
|
|
803
|
+
const tool = mode() || "hand"
|
|
804
|
+
for (const t of /** @type {NodeListOf<HTMLElement>} */ (document.querySelectorAll("[data-tool]"))) {
|
|
805
|
+
t.classList.toggle("on", t.dataset.tool === tool)
|
|
806
|
+
t.setAttribute("aria-selected", String(t.dataset.tool === tool))
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
updateLens()
|
|
810
|
+
renderLayers()
|
|
811
|
+
const target = mode() && hovered && hovered.isConnected ? hovered : null
|
|
812
|
+
hl.style.display = target ? "block" : "none"
|
|
813
|
+
if (target) {
|
|
814
|
+
boxAt(target, hl)
|
|
815
|
+
// A picked ::before/::after: its host, dashed, labelled with the pseudo.
|
|
816
|
+
const p = pick()
|
|
817
|
+
const pseudo = p && p.el === target && p.pseudo
|
|
818
|
+
hl.className = (mode() === "select" ? "select" : "") + (pseudo ? " pseudo" : "")
|
|
819
|
+
hl.dataset.label = pseudo ? `${shortLabel(target)}${p.pseudo}` : `<${shortLabel(target)}>`
|
|
820
|
+
}
|
|
821
|
+
if (D.scopeEl && D.scopeEl.isConnected) boxAt(D.scopeEl, scopeBox)
|
|
822
|
+
else scopeBox.style.display = "none"
|
|
823
|
+
|
|
824
|
+
// Numbered pins on the notes of the branch in view.
|
|
825
|
+
let doc = null
|
|
826
|
+
try {
|
|
827
|
+
doc = D.frame.contentDocument
|
|
828
|
+
} catch {}
|
|
829
|
+
const f = D.frame.getBoundingClientRect()
|
|
830
|
+
const seen = new Set()
|
|
831
|
+
D.notes.forEach((n, i) => {
|
|
832
|
+
if (n.branchId !== D.activeId || !doc || s.seeking) return
|
|
833
|
+
const p = anchorOf(n.el)
|
|
834
|
+
let pin = pinEls.get(n.id)
|
|
835
|
+
if (!pin) {
|
|
836
|
+
pin = document.createElement("button")
|
|
837
|
+
pin.className = "canvas-pin"
|
|
838
|
+
pin.dataset.note = n.id
|
|
839
|
+
$("#wb-pins").appendChild(pin)
|
|
840
|
+
pinEls.set(n.id, pin)
|
|
841
|
+
}
|
|
842
|
+
pin.textContent = String(i + 1)
|
|
843
|
+
// A note made at another moment: its pin is dimmed (the element may look
|
|
844
|
+
// different now); clicking it goes there.
|
|
845
|
+
pin.classList.toggle("away", Math.abs(n.t - shownTime(s)) > 60)
|
|
846
|
+
pin.style.background = NOTE_FILL[n.status] || NOTE_FILL.pending
|
|
847
|
+
pin.title = n.text
|
|
848
|
+
pin.style.left = p.x + "px"
|
|
849
|
+
pin.style.top = p.y + "px"
|
|
850
|
+
seen.add(n.id)
|
|
851
|
+
})
|
|
852
|
+
paintCount(D.notes.filter((n) => n.branchId === D.activeId).length)
|
|
853
|
+
for (const [id, pin] of pinEls) {
|
|
854
|
+
if (seen.has(id)) continue
|
|
855
|
+
pin.remove()
|
|
856
|
+
pinEls.delete(id)
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
// ---- the count: how many notes this timeline has, as a badge on the notes icon ----------
|
|
861
|
+
|
|
862
|
+
const countEl = $(".notes-count")
|
|
863
|
+
let shownCount = -1
|
|
864
|
+
function paintCount(n) {
|
|
865
|
+
if (n === shownCount) return
|
|
866
|
+
shownCount = n
|
|
867
|
+
countEl.querySelector(".n").textContent = n > 99 ? "99+" : String(n)
|
|
868
|
+
countEl.classList.toggle("has", n > 0)
|
|
869
|
+
countEl.setAttribute("aria-label", n === 0 ? "No notes on this timeline" : n === 1 ? "1 note on this timeline" : `${n} notes on this timeline`)
|
|
870
|
+
}
|